PostgreSQL 容量规划、读扩展、分片与水平扩展
从可持续容量、容量余量、读副本、分区、应用层分片、分片键、Fan-Out、跨分片事务、重分片与 Go Shard Router 出发,理解 PostgreSQL 水平扩展设计。
第 19 章:容量规划、读扩展、分片与水平扩展
技术基线:PostgreSQL 18、Go、
pgx/v5、pgxpool。截至 2026 年 6 月,PostgreSQL 18 是当前稳定主版本,PostgreSQL 19 仍属于开发版本。(PostgreSQL)
| 版本 | 与本章直接相关的能力 |
|---|---|
| PostgreSQL 14 | in_hot_standby 可直接判断当前会话是否处于 Hot Standby |
| PostgreSQL 15 | 逻辑复制 Publication 支持列列表和行过滤 |
| PostgreSQL 16 | 引入 pg_stat_io;支持从物理 Standby 进行逻辑解码 |
| PostgreSQL 17 | 引入 pg_stat_checkpointer;增强逻辑复制槽 Failover,加入 pg_createsubscriber |
| PostgreSQL 18 | 引入 AIO 子系统、uuidv7()、按字节统计的 pg_stat_io、每 Backend I/O/WAL 统计 |
这些版本差异分别来自各主版本的官方 Release Notes。(PostgreSQL)
1. 本章定位
本章解决的是:当单个 Primary 的容量、延迟或故障域已经不能满足业务要求时,系统应该如何扩展。
真正的生产问题通常不是“表有十亿行,所以要分片”,而是以下某一个可测量边界已经逼近:
- CPU 长时间饱和,排队持续增长;
- 热数据集无法被内存和 Page Cache 有效容纳;
- 数据盘、WAL 盘、网络带宽或同步写延迟达到边界;
- 单个热点行、索引页或租户限制了写吞吐;
- Primary 的读取负载挤占写入、Vacuum 和复制资源;
- 连接池等待已经成为主要延迟来源;
- 单个故障域的 RPO、RTO 或爆炸半径不可接受;
- 即使经过 SQL、索引、数据模型、缓存和垂直扩展,容量余量仍不足。
本章依赖:
- 第 6~8 章的执行计划和索引能力;
- 第 11~13 章的锁、热点、WAL 和 Checkpoint;
- 第 14 章的分区表;
- 第 16~18 章的 pgx、连接池、Outbox 和性能工程。
本章不详细展开:
- 备份与 PITR 的具体操作;
- 物理复制参数;
- 逻辑复制协议实现;
- Patroni、Fencing 和自动故障转移。
这些内容将在后续章节深入讨论,但本章会分析它们在多分片环境中的系统影响。
2. 可验证的学习目标
完成本章后,应能够:
- 根据 P95/P99、TPS、连接池等待、WAL、I/O 和复制延迟定义单节点的可持续容量。
- 证明瓶颈究竟位于 CPU、内存、I/O、WAL、锁、连接还是应用排队。
- 按照“优化、垂直扩展、缓存、读副本、分区、拆分、分片”的顺序完成选型。
- 解释为什么读副本不能增加 Primary 的写吞吐。
- 准确区分分区、应用层分片和数据库级分片。
- 为 Tenant、Hash、Range 和 Directory-Based Sharding 选择分片键。
- 使用负载而不是行数判断数据倾斜和 Hot Shard。
- 设计有界 Fan-Out、跨分片排序、聚合和 Keyset Pagination。
- 判断跨分片事务应使用本地事务、2PC、Saga 还是 Outbox。
- 设计包含 Backfill、CDC、校验、Fencing、Cutover 和 Rollback 的 Resharding 流程。
- 为每个分片设计连接池预算、复制、备份、Failover 和可观测性。
- 使用 Go 和
pgxpool实现一个有界、可取消、支持部分失败的 Shard Router。
3. 核心术语
| 中文名称 | 英文名称 | 准确定义 | 易混淆概念 | 层次 |
|---|---|---|---|---|
| 可持续容量 | Sustainable Capacity | 在代表性工作负载下,长期满足延迟、错误率和稳定性 SLO 的最大负载 | 瞬时峰值 | 系统 |
| 容量余量 | Headroom | 可持续容量与当前峰值需求之间的安全空间 | 空闲 CPU 百分比 | 系统 |
| 垂直扩展 | Vertical Scaling | 增加单个节点的 CPU、内存、存储或网络能力 | 水平扩展 | 基础设施 |
| 读扩展 | Read Scaling | 将可容忍一致性延迟的读取转移到缓存或副本 | 写扩展 | 架构 |
| 分区 | Partitioning | 在同一 PostgreSQL 集群内将一个逻辑表拆为多个物理子表 | 分片 | 数据库 |
| 分片 | Sharding | 将数据分布到多个独立数据库或故障域 | 分区 | 分布式系统 |
| 分片键 | Shard Key | 决定一行数据应位于哪个分片的业务属性 | 主键、分区键 | 数据模型 |
| 虚拟分片 | Virtual Shard | 位于业务键和物理分片之间的稳定逻辑桶 | PostgreSQL 分区 | 路由层 |
| 租户分片 | Tenant Sharding | 将同一租户的关联数据放到同一分片 | Schema-per-tenant | 数据模型 |
| 哈希分片 | Hash Sharding | 对分片键执行稳定哈希后映射到分片 | Hash Partitioning | 路由层 |
| 范围分片 | Range Sharding | 按连续键范围分配数据 | 时间分区 | 路由层 |
| 目录分片 | Directory-Based Sharding | 通过显式元数据表查询实体所在分片 | 一致性哈希 | 控制面 |
| 热分片 | Hot Shard | 请求、CPU、WAL、锁或数据增长显著高于其他分片 | 大分片 | 运行时 |
| 数据倾斜 | Data Skew | 数据量或负载在分片间分布不均 | 热点键 | 运行时 |
| 扇出查询 | Fan-Out Query | 将一个逻辑查询发送到多个分片并合并结果 | 并行查询 | 查询层 |
| 全局标识 | Global ID | 在所有分片中唯一的业务标识 | 全局顺序 | 数据模型 |
| 双路由 | Dual Routing | 迁移期间同时保留源和目标位置的路由信息 | 双写 | 控制面 |
| 双写 | Dual Write | 一个业务请求直接向两个独立数据库提交写入 | CDC | 数据路径 |
| 两阶段提交 | Two-Phase Commit | 先 Prepare 所有参与者,再统一 Commit 或 Rollback | Saga | 事务 |
| 补偿事务 | Saga | 将全局业务拆为多个本地事务,并通过补偿处理失败 | 数据库回滚 | 业务层 |
| 事务发件箱 | Transactional Outbox | 在本地业务事务中原子写入业务数据和待发布事件 | 直接双写 | 数据路径 |
| 重分片 | Resharding | 在线改变数据到物理分片的映射 | Rebalance | 运维 |
| 路由世代 | Route Epoch | 用于识别和拒绝陈旧路由写入的单调递增版本 | Schema Version | 控制面 |
4. 整体心智模型
4.1 扩展决策树

顺序非常重要:
SQL/索引优化 → 数据模型优化 → 垂直扩展 → 缓存 → 读副本 → 分区 → 工作负载拆分 → 分片
分片不是性能调优的第一步,而是将单机数据库问题转化为分布式系统问题。它可能增加总容量,但同时引入路由、部分失败、跨分片查询、全局约束、迁移和多故障域恢复等复杂度。
4.2 分片系统整体结构

4.3 四类流
数据流
- 请求带上
tenant_id或其他 Shard Key。 - Router 查询本地元数据缓存。
- Router 选择分片和该分片的连接池。
- 强一致读取和写入进入 Primary。
- 可接受陈旧数据的读取进入 Replica。
- Fan-Out 查询进入多个分片,结果在应用层归并。
控制流
- Metadata Service 决定逻辑键到物理分片的映射。
- Route Epoch 决定某个写入是否仍使用有效路由。
- Resharding Controller 驱动 Backfill、CDC、校验和 Cutover。
- Admission Control 限制总请求、单租户请求和 Fan-Out 并发。
状态变化
典型租户迁移状态为:
HOME_OLD
→ COPYING
→ CATCHING_UP
→ VALIDATING
→ FENCING
→ HOME_NEW
→ FINALIZING
故障路径
- Replica 延迟:读取陈旧或回退 Primary。
- 单分片 Primary 故障:仅该分片不可写,但 Router 必须快速识别拓扑变化。
- Metadata 不可用:已有缓存路由可短时工作;未知或迁移中的写入应 Fail Closed。
- Fan-Out 部分失败:根据 API 契约返回部分结果、整体失败或降级结果。
- CDC 失败:源分片继续作为唯一写入权威,目标不得提前 Cutover。
- 陈旧 Router 写源分片:必须通过 Route Epoch 或 Fencing 拒绝。
5. 使用方式
5.1 不要按表行数定义容量
“表有多少行”不能直接回答是否需要分片。
一张数十亿行、只追加、按时间分区、查询高度可裁剪的表,可能长期运行良好。另一张只有几百万行、所有请求都更新同一个租户余额或索引末端页的表,可能已经达到并发边界。
应定义工作负载向量:
W = {
读取 QPS,
写入 TPS,
每事务 SQL 数量,
每操作行数,
平均与 P99 行宽,
随机/顺序 I/O 比例,
WAL bytes/s,
活跃事务数,
Fan-Out 宽度,
热租户占比,
复制因子,
SLO
}
可持续容量不是压测中出现过的最高 TPS,而是:
在代表性数据量、数据分布、缓存冷热状态和并发下,经历多个 Checkpoint、Autovacuum 和业务峰谷周期后,仍满足 P95/P99、错误率、复制延迟和队列稳定性要求的最高持续负载。
容量余量可表示为:
Headroom = 1 - PeakDemand / SustainableCapacity
该公式必须分别应用于:
- CPU;
- 主数据盘读写;
- WAL 写入;
- 网络;
- 连接;
- 每个热分片;
- CDC 和复制带宽;
- 恢复带宽。
不能只看整套集群的平均值。平均利用率 30% 可能掩盖某个热分片已经达到 95%。
对于可重新分配负载的 N 个同构节点,简单的 N+1 估算为:
FailoverUtilization ≈ NormalUtilization × N / (N - 1)
这只是容量检查公式,不是统一的安全阈值。最终边界必须由硬件、工作负载、排队曲线和 SLO 实测确定。
5.2 证明真正瓶颈
5.2.1 版本和数据规模
SELECT version();
SELECT
datname,
pg_size_pretty(pg_database_size(datname)) AS database_size
FROM pg_database
WHERE datallowconn
ORDER BY pg_database_size(datname) DESC;
SELECT
relid::regclass AS relation,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
n_live_tup,
n_dead_tup
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 30;
5.2.2 数据库级吞吐和临时文件
SELECT
datname,
numbackends,
xact_commit,
xact_rollback,
blks_read,
blks_hit,
temp_files,
temp_bytes,
deadlocks,
stats_reset
FROM pg_stat_database
WHERE datname = current_database();
容量判断必须使用固定时间窗口内的增量,不能直接比较两个不同重置时间的累计值。
5.2.3 Top SQL
pg_stat_statements 需要加入 shared_preload_libraries,首次启用通常需要重启,然后在目标数据库执行 CREATE EXTENSION pg_stat_statements。它能记录调用次数、执行时间、Buffer、Temporary Block 和 WAL 等指标。(PostgreSQL)
SELECT
queryid,
calls,
round(total_exec_time::numeric, 2) AS total_exec_ms,
round(mean_exec_time::numeric, 2) AS mean_exec_ms,
rows,
shared_blks_hit,
shared_blks_read,
temp_blks_written,
wal_records,
wal_fpi,
wal_bytes,
left(query, 160) AS sample
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 30;
重点观察:
total_exec_time:总体资源消耗;mean_exec_time:平均延迟,不等于 P99;shared_blks_read:进入 PostgreSQL Buffer 的块读取;temp_blks_written:排序或 Hash 溢出;wal_bytes:该 SQL 产生的写放大;calls:低延迟但高频 SQL 也可能是首要成本。
5.2.4 I/O
pg_stat_io 从 PostgreSQL 16 开始提供;PostgreSQL 18 增加 read_bytes、write_bytes、extend_bytes 和 WAL I/O 行。该视图不能区分数据来自物理磁盘还是操作系统 Page Cache,因此必须结合操作系统指标。(PostgreSQL)
-- [PG18]
SELECT
backend_type,
object,
context,
reads,
read_bytes,
round(read_time::numeric, 2) AS read_ms,
writes,
write_bytes,
round(write_time::numeric, 2) AS write_ms,
evictions,
fsyncs,
round(fsync_time::numeric, 2) AS fsync_ms,
stats_reset
FROM pg_stat_io
ORDER BY
COALESCE(read_time, 0) + COALESCE(write_time, 0) DESC;
5.2.5 WAL
-- [PG18]
SELECT
wal_records,
wal_fpi,
wal_bytes,
wal_buffers_full,
stats_reset
FROM pg_stat_wal;
wal_bytes 是累计 WAL 字节数,wal_fpi 是 Full Page Image 数量,wal_buffers_full 上升表示 WAL Buffer 曾因填满而触发写出。(PostgreSQL)
5.2.6 Checkpoint
-- [PG18]
SELECT
num_timed,
num_requested,
num_done,
write_time,
sync_time,
buffers_written,
stats_reset
FROM pg_stat_checkpointer;
pg_stat_checkpointer 从 PostgreSQL 17 开始独立提供;PostgreSQL 18 增加了 num_done。(PostgreSQL)
5.2.7 连接与等待
SELECT
datname,
application_name,
state,
wait_event_type,
wait_event,
count(*) AS sessions
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY
datname,
application_name,
state,
wait_event_type,
wait_event
ORDER BY sessions DESC;
必须区分:
active:正在执行 SQL;idle:占有 Backend,但未执行 SQL;idle in transaction:事务未结束,可能持有锁和旧 Snapshot;wait_event_type = 'Lock':重量级锁等待;wait_event_type = 'IO':等待 I/O;wait_event_type = 'Client':数据库通常在等应用。
5.2.8 复制延迟
SELECT
application_name,
client_addr,
state,
sync_state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::bigint
) AS replay_lag_bytes,
write_lag,
flush_lag,
replay_lag
FROM pg_stat_replication;
replay_lsn 表示 Standby 已经重放到的位置。(PostgreSQL)
5.3 每一种扩展手段解决什么问题
| 阶段 | 适用证据 | 不能解决的问题 |
|---|---|---|
| SQL/索引优化 | 少数 SQL 占主要执行时间、读块或临时文件 | 根本性的单写域边界 |
| 数据模型优化 | 行宽过大、主键随机、约束缺失、过度更新 | 总数据和请求持续增长 |
| 垂直扩展 | CPU、RAM、I/O 可通过更高规格经济扩展 | 单机故障域、物理上限 |
| 缓存 | 重复读取、高命中、允许有限陈旧 | 强一致写入和热点更新 |
| 读副本 | Primary 主要被只读查询占用 | 写吞吐 |
| 分区 | 查询可裁剪、历史数据维护成本高 | 跨机器写扩展 |
| 工作负载拆分 | OLTP、Analytics、Search 相互干扰 | 单一 OLTP 域内部热点 |
| 分片 | 单机写入、容量或故障域已被证实 | 全局事务和热点键复杂度 |
5.4 垂直扩展
垂直扩展通常是分片前成本最低的手段。
CPU
增加 CPU 适用于:
- 查询计算和 Join 已经优化;
- 并行查询确实能利用更多 Core;
- 高并发下 CPU Run Queue 持续升高;
- 锁和 I/O 不是主要等待。
增加 CPU 不能修复:
- 单热点行;
- 单索引页竞争;
- WAL Flush 延迟;
- 无界连接数;
- Fan-Out 放大。
内存
更多内存可能扩大:
- PostgreSQL
shared_buffers; - 操作系统 Page Cache;
- Sort 和 Hash 的可用工作内存;
- 连接和后台进程的总体容纳能力。
但必须注意:
潜在工作内存
≈ 活跃查询数
× 每查询同时活动的 Sort/Hash 节点数
× work_mem
不能把 work_mem × max_connections 当作精确内存,也不能忽视一个查询含多个可同时分配内存的执行节点。
存储
需要分别识别:
- 随机读取延迟;
- 顺序扫描带宽;
- WAL 顺序写入和 Flush 延迟;
- Checkpoint 写入峰值;
- Vacuum 和 Backfill 带宽;
- Resharding 的源读、目标写和 CDC 叠加。
PostgreSQL 18 的 AIO 能让 Backend 排队多个读取请求,并改善顺序扫描、Bitmap Heap Scan、Vacuum 等操作,但它不会消除底层设备的 IOPS、带宽和延迟上限。(PostgreSQL)
5.5 读副本与读写分离
Hot Standby 连接是严格只读的,连临时表都不能写。Standby 数据只有在提交记录被重放后,才对新的 Snapshot 可见,因此它与 Primary 是最终一致的。(PostgreSQL)
因此:
读副本能够转移读取、报表和备份负载,但不能扩展 Primary 的业务写入。
它可能间接释放 Primary 的 CPU 和数据盘读取能力,但写事务仍必须在 Primary 上:
- 生成 Tuple;
- 修改索引;
- 获取锁;
- 产生 WAL;
- 完成提交持久化。
一致性路由策略
| 读取类型 | 推荐路由 |
|---|---|
| 余额、权限、刚提交订单 | Primary |
| 用户刚写后立即读 | Primary Stickiness 或 LSN 等待 |
| 商品目录、历史订单 | Replica |
| 报表、导出、离线计算 | 专用 Replica 或分析库 |
| Replica Lag 超标 | 回退 Primary、返回陈旧标记或拒绝 |
LSN Read-Your-Writes Token
在成功提交后的同一 Primary 连接上取得一个保守的 WAL 位置:
SELECT pg_current_wal_insert_lsn();
在 Replica 上检查:
SELECT COALESCE(
pg_last_wal_replay_lsn() >= $1::pg_lsn,
false
);
应用在有限时间内轮询;超时后回退 Primary 或明确返回“数据仍在同步”。
该位置可能晚于当前事务的精确 Commit LSN,因此可能多等待一小段 WAL,但不会少等。
Recovery Conflict
Standby 上的长查询可能与 Primary 已经发生的 DDL、Vacuum Cleanup 或其他 WAL 重放动作冲突。PostgreSQL 必须在“延迟 WAL 重放”和“取消 Standby 查询”之间选择,不能无限等待。(PostgreSQL)
因此,分析型副本需要同时监控:
- Replay Lag;
- 被取消查询数;
- 长事务;
pg_stat_database_conflicts;- Primary Bloat;
hot_standby_feedback带来的清理延迟风险。
5.6 缓存、Materialized View 与汇总表
Cache-Aside
1. 读取缓存
2. Miss 后读取 PostgreSQL
3. 将结果写入缓存
4. 写事务提交后,通过 Outbox/CDC 失效缓存
生产设计必须包括:
- TTL;
- TTL 抖动;
- Singleflight 或 Request Coalescing;
- Negative Cache;
- 最大对象大小;
- 租户级缓存配额;
- 缓存不可用时的回源限流;
- 删除、更新和事务提交后的失效语义。
缓存不能把最终一致读取伪装成强一致读取。
Materialized View
Materialized View 保存查询结果,可通过刷新重新计算:
CREATE MATERIALIZED VIEW tenant_daily_revenue AS
SELECT
tenant_id,
created_at::date AS business_date,
sum(amount_cents) AS revenue_cents
FROM orders
GROUP BY tenant_id, created_at::date;
CREATE UNIQUE INDEX tenant_daily_revenue_uq
ON tenant_daily_revenue (tenant_id, business_date);
REFRESH MATERIALIZED VIEW CONCURRENTLY tenant_daily_revenue;
REFRESH MATERIALIZED VIEW 会替换物化视图内容。CONCURRENTLY 可以避免阻塞普通查询,但要求物化视图已经填充,并存在覆盖所有行、仅由普通列组成的 UNIQUE 索引;同一个物化视图一次只能运行一个 Refresh。(PostgreSQL)
汇总表
高频增量统计更适合显式汇总表:
INSERT INTO tenant_daily_revenue (
tenant_id,
business_date,
revenue_cents
)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, business_date)
DO UPDATE
SET revenue_cents =
tenant_daily_revenue.revenue_cents
+ EXCLUDED.revenue_cents;
但这一行本身可能成为热点。可进一步设计为:
tenant_id + business_date + bucket
读取时再 SUM 多个 Bucket。
5.7 分区不是分片
PostgreSQL 分区表的父表本身是没有存储的虚拟表,数据位于普通的子表中,插入时由分区键路由到目标分区。(PostgreSQL)
CREATE TABLE events (
tenant_id bigint NOT NULL,
event_id uuid NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb NOT NULL,
PRIMARY KEY (tenant_id, event_id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_06
PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
分区主要改善:
- Partition Pruning;
- 历史数据快速 Detach/Drop;
- 单个索引尺寸;
- Vacuum 和维护范围;
- 分层存储和生命周期管理。
它仍然共享:
- 同一个 Primary;
- 同一组 Backend;
- 同一份 WAL;
- 同一个 Buffer Pool;
- 同一个故障域;
- 同一个写入协调域。
因此:
分区可以让大表更易管理,但不是跨机器水平写扩展。
分区表上的 PRIMARY KEY 或 UNIQUE 约束通常必须包含所有分区键,因为单个子分区索引只能直接保证分区内部唯一。(PostgreSQL)
5.8 分片模型
| 模型 | 路由方式 | 优点 | 主要风险 |
|---|---|---|---|
| Tenant Sharding | tenant_id → shard | 租户内事务和 Join 易保持本地 | 大租户成为 Hot Shard |
| Hash Sharding | hash(key) → bucket → shard | 通常较均匀 | 范围查询 Fan-Out,扩容需迁移 |
| Range Sharding | range(key) → shard | 范围查询局部化 | 最新范围、单地域可能过热 |
| Directory-Based | 查元数据目录 | 任意迁移和隔离 | 元数据服务成为关键控制面 |
| Schema-Based | Schema 映射到节点 | 适合租户 Schema 隔离 | Schema 数和迁移管理复杂 |
| Database-per-Tenant | 一个或一组租户一个 Database | 隔离强 | Pool、DDL、备份和运维对象爆炸 |
分片键选择标准
优秀的 Shard Key 应尽量满足:
- 高频查询通常携带该键。
- 高频事务中的相关数据能够共置。
- 键值稳定,不会频繁改变。
- 按请求、CPU、WAL 和容量增长而不是仅按行数分布。
- 高基数且不存在少量超大键。
- 数据迁移可以按该键独立完成。
- 满足地域、合规和数据驻留要求。
- 能通过 Route Epoch 识别陈旧写入。
Hot Shard 与 Hot Key
- Hot Shard:某个物理分片总体过热。
- Hot Key:某一个租户、账户、商品或计数器过热。
- Hot Row:并发事务争用同一个 Tuple。
- Hot Index Page:大量插入或更新争用同一索引页。
增加物理分片只能解决部分 Hot Shard。
它不能自动拆开:
hash(tenant_id = 42)
因为同一个键始终映射到同一个逻辑桶。
虚拟分片
tenant_id
↓ stable_hash
virtual_bucket
↓ metadata mapping
physical_shard
虚拟分片的优点是迁移粒度更小:扩容时移动 Bucket,而不必改变所有键的哈希结果。
但它仍然无法拆分一个单独的热租户,除非:
- 将该租户迁移到专用分片;
- 使用二级键,如
(tenant_id, entity_id); - 对热点业务做 Bucket 化;
- 将读、写或分析工作负载拆开。
5.9 Global ID、UUID 与 Snowflake 类 ID
本地 Sequence
每个分片独立使用:
GENERATED BY DEFAULT AS IDENTITY
会产生跨分片冲突。可改为:
(shard_id, local_sequence)
但业务主键会暴露物理拓扑,并增加迁移难度。
UUIDv4
优点:
- 无需中心服务;
- 跨分片碰撞概率极低;
- 客户端可提前生成。
缺点:
- 随机插入对 B-tree 局部性不友好;
- 本身不携带路由信息;
- 不能表示全局提交顺序。
UUIDv7
PostgreSQL 18 内置 uuidv7()。UUIDv7 按时间有序,包含毫秒、亚毫秒和随机部分。(PostgreSQL)
SELECT uuidv7();
它能改善时间局部性,但仍然:
- 不是严格的全局事务顺序;
- 不代表数据库 Commit 顺序;
- 可能暴露大致创建时间;
- 不能自动确定分片位置。
Snowflake 类 ID
典型结构包含:
时间戳 + Worker ID + 每时间片序列
必须解决:
- Worker ID 唯一租约;
- 时钟回拨;
- 单时间片序列耗尽;
- 位宽升级;
- 多地域时钟偏差;
- ID 生成服务降级;
- Worker ID 重用;
- 时间溢出策略。
Snowflake 类 ID 只解决“分布式唯一且大致有序”,不自动解决:
- 数据路由;
- 全局唯一约束;
- 跨分片事务;
- 全局分页的一致 Snapshot。
5.10 路由、Fan-Out、排序、聚合与分页
路由元数据
生产级路由记录至少包含:
routing_key
write_shard
read_shards
shadow_shard
state
route_epoch
updated_at
Router 应:
- 本地缓存路由;
- 使用单调 Route Epoch;
- 对迁移中写入进行 Fencing;
- 对未知写路由 Fail Closed;
- 记录实际访问的分片;
- 限制缓存陈旧时间;
- 在元数据变更后主动失效本地缓存。
Fan-Out 查询
假设一个请求访问 N 个分片,每个分片独立失败概率为 p,至少一个失败的近似概率为:
1 - (1 - p)^N
实际分片故障并不完全独立,但该公式说明:Fan-Out 越宽,遇到慢节点或失败节点的机会越大。
全局延迟通常接近:
最慢分片延迟 + 应用归并时间 + 网络开销
必须限制:
- 单请求 Fan-Out 宽度;
- 单请求并行查询数;
- 全局 Fan-Out 并发;
- 每分片并发;
- 每分片超时;
- 返回行数;
- 内存中的合并结果大小。
跨分片排序
对于全局 Top K:
- 每个分片使用完全相同的过滤和排序。
- 每个分片返回本地 Top K。
- Router 使用
(sort_key, global_id)作为全序键进行 K 路归并。 - 只保留全局前 K 条。
SELECT ...
FROM orders
WHERE created_at < $1
ORDER BY created_at DESC, order_id DESC
LIMIT $2;
order_id 用来打破 created_at 相同的 Tie。
跨分片聚合
可分解聚合适合本地计算后合并:
| 聚合 | 合并方法 |
|---|---|
COUNT | 各分片相加 |
SUM | 各分片相加 |
MIN/MAX | 对局部结果再次取最值 |
AVG | 合并 SUM 和 COUNT |
DISTINCT COUNT | 精确合并成本高,可考虑集中计算或 Sketch |
| Percentile | 需合并原始值、直方图或可合并 Sketch |
跨分片分页
全局 OFFSET 会导致:
- 每个分片读取
OFFSET + LIMIT; - 页码越深,读放大越严重;
- 并发写入下容易重复或遗漏;
- Router 需要合并大量无用行。
更适合使用 Keyset Cursor:
{
last_sort_key,
last_global_id,
route_epoch,
per_shard_cursor
}
迁移期间还需要按 Global ID 去重,防止源和目标同时出现在读取集合中。
5.11 跨分片事务
最佳方案不是先选分布式事务协议,而是先修改数据模型,使高频事务保持单分片。
本地事务
tenant_id=42 的订单、库存预留、支付状态
全部共置于同一分片
优点:
- 使用 PostgreSQL 原生事务;
- 锁、Snapshot、约束和 Commit 都在一个节点;
- 故障处理最简单。
2PC
PostgreSQL 支持:
BEGIN;
-- 本地修改
PREPARE TRANSACTION 'global-tx-123';
COMMIT PREPARED 'global-tx-123';
-- 或 ROLLBACK PREPARED
官方明确说明,PREPARE TRANSACTION 主要用于外部事务管理器,不适合普通应用直接使用。Prepared Transaction 会继续持有锁,并妨碍 Vacuum 清理;没有可靠事务管理器时,通常应保持 max_prepared_transactions = 0。(PostgreSQL)
2PC 需要处理:
- Coordinator 日志持久化;
- Coordinator 故障恢复;
- Prepared Transaction 泄漏;
- 参与者不可达;
- 超时后不能自行猜测 Commit 或 Rollback;
- 跨节点死锁;
- Failover 后 Prepared 状态恢复;
- 人工处置 Runbook。
Saga
将全局操作拆为:
本地事务 A
→ 事件
→ 本地事务 B
→ 事件
→ 本地事务 C
失败时运行补偿:
Compensate C
→ Compensate B
→ Compensate A
补偿不是物理回滚。已发送通知、已产生外部费用或已被用户看到的状态,可能无法完全撤销。
Outbox
推荐模式:
同一个本地事务:
更新业务表
写 Outbox
提交后:
Worker/CDC 读取 Outbox
幂等写目标系统
标记事件完成
它提供本地原子性和 At-Least-Once 传递,不提供跨数据库瞬时原子可见性。
5.12 分片级备份、复制、Failover 与元数据服务
每个物理分片都应被视为独立 PostgreSQL 故障域:
Shard A Primary
├── Synchronous/Asynchronous Replica
├── Base Backup
├── WAL Archive
└── PITR Runbook
需要分别定义:
- Shard RPO;
- Shard RTO;
- 复制模式;
- Backup Retention;
- 恢复带宽;
- Failover 目标;
- 旧 Primary Fencing;
- Router 拓扑刷新;
- 应用重连。
全局恢复难点
每个分片自己的 LSN 只在该 PostgreSQL 集群和 Timeline 中有意义。
因此不能说:
将所有分片恢复到同一个 LSN
跨分片一致恢复通常需要:
- 业务写入屏障;
- 全局 Backup Epoch;
- 每分片记录屏障对应位置;
- 或接受分片间最终一致,并通过业务事件修复。
元数据服务
路由元数据的错误可能把写入发送到错误分片,因此其要求通常高于普通缓存:
- 强一致写入;
- 高可用;
- 单调 Route Epoch;
- CAS 更新;
- 审计记录;
- 只读快照;
- 明确 Fencing;
- 故障时已有路由与未知路由采用不同策略。
5.13 Resharding
安全状态机

具体过程
-
Prepare
- 创建目标表、索引、约束和配额;
- 验证目标版本和 Schema;
- 建立迁移记录和 Route Epoch。
-
确定唯一写入权威
- 源分片仍接收业务写入;
- 同一事务写源业务数据和 Outbox;
- 不让应用直接向两个数据库盲目双写。
-
Backfill
- 按稳定主键分批;
- 控制每批行数和事务时长;
- 设置速率上限;
- 使用幂等 Upsert;
- 避免长事务和全表锁。
-
CDC Catch-Up
- 重放 Backfill 期间的新增和修改;
- 按实体或事务保持必要顺序;
- 使用事件 ID 去重;
- 删除操作使用 Tombstone;
- 记录复制游标。
-
Validation
- 按 Bucket 比较行数;
- 比较业务聚合;
- 比较版本号;
- 抽样或精确比较行;
- 检查目标约束;
- 检查 Outbox Lag。
-
双路由
- 写入仍只去源分片;
- 目标可用于 Shadow Read;
- 比较源和目标查询结果;
- 不能把双路由等同于直接双写。
-
Fencing
- Route Epoch 递增;
- 陈旧 Router 的写入被拒绝;
- 短暂暂停该租户写入;
- 等待在途事务结束;
- 重放最终增量。
-
Cutover
- CAS 更新路由到目标;
- 目标成为唯一写入权威;
- Router 主动刷新;
- 继续监控延迟、错误和差异。
-
Rollback Window
- 如果目标已接受新写入,仅翻转元数据是不安全的;
- 必须保持反向 CDC,或先把目标新增变化同步回源。
-
Finalize
- 过观察期后停止反向同步;
- 归档旧数据;
- 释放旧分片容量;
- 保留迁移审计记录。
PostgreSQL 逻辑复制通常先复制初始 Snapshot,再持续发送变化;在同一 Subscription 内,Subscriber 按 Publisher 顺序应用,以保持该订阅范围内的事务一致性。(PostgreSQL)
5.14 Citus 作为可选方案
Citus 可以作为数据库级水平扩展方案,但不是“安装扩展即可自动解决所有分布式问题”。
Citus 支持:
- 按 Distribution Column 对行进行分布;
- Tenant ID 等键的共置;
- Row-Based Sharding;
- Schema-Based Sharding;
- Coordinator 路由;
- Shard Rebalancing。
Distribution Column 决定数据分布以及相关行能否共置。选择错误的 Distribution Column 会导致网络传输、跨节点操作和 SQL 能力受限。(Citus Documentation)
增加 Worker 后,现有 Shard 不会自动全部迁移;需要显式 Rebalance。Citus 的在线 Shard Rebalancing 使用 PostgreSQL 逻辑复制进行迁移,并在元数据切换阶段对 Shard 获取短暂写锁。(Citus Documentation)
使用前仍需评估:
- Coordinator 高可用;
- Distribution Column;
- Co-location;
- 跨 Shard SQL;
- Shard 数量和连接放大;
- Rebalance 带宽;
- Worker 备份与恢复;
- 扩展升级;
- 与原生 PostgreSQL 行为的差异。
6. 底层原理
6.1 单 Primary 写入路径
应用 goroutine
→ pgxpool Acquire
→ PostgreSQL Backend
→ 获取 Tuple/Relation/Transaction Lock
→ 修改 Buffer 中的 Page
→ 生成 Heap/Index WAL Record
→ 写入 Commit Record
→ 根据 synchronous_commit 等待持久化条件
→ 返回 Commit 结果
→ WAL Sender 发送
→ Standby Write / Flush / Replay
单 Primary 并不意味着所有写入完全串行,但所有写入最终共享同一组资源:
- Buffer Manager;
- Lock Manager;
- WAL 插入和写出;
- Checkpointer;
- Storage;
- Catalog;
- Vacuum;
- 单节点 CPU、内存和网络。
读副本只消费这些 WAL,不会替 Primary 执行业务写入。
6.2 分区写入路径
INSERT 父表
→ 根据 Partition Key 选择子分区
→ 修改子分区 Heap
→ 修改子分区 Index
→ 在同一个 Primary 产生 WAL
分区减少单个对象尺寸和维护范围,但没有新增独立写入节点。
6.3 分片写入路径
请求携带 tenant_id
→ Router 查询 Route
→ 选定一个 Pool
→ 在目标 PostgreSQL 执行本地事务
→ 返回结果
一旦请求涉及两个分片,就不再共享:
- Snapshot;
- Transaction ID;
- Lock Table;
- WAL Stream;
- Commit Point;
- Sequence;
- Constraint Enforcement。
此时需要由应用或分布式数据库层重新建立全局语义。
6.4 直接双写为什么危险
BEGIN Source
写 Source
COMMIT Source -- 成功
BEGIN Target
写 Target
COMMIT Target -- 网络超时
应用无法仅凭超时判断 Target 是否提交。
可能结果:
| Source | Target | 应用看到 |
|---|---|---|
| Commit | Commit | Target Commit 超时 |
| Commit | Rollback | Target Commit 超时 |
| Commit | 未执行 | 网络错误 |
| Rollback | Commit | 重试或编排错误 |
Outbox 将第一个远程写转换为源数据库内部的本地原子写,从而减少状态组合。
7. 内部数据结构和状态
| 对象 | 单分片语义 | 跨分片后 |
|---|---|---|
| Heap Page | 仅存在于一个 PostgreSQL 实例 | 无全局 Page |
| Tuple | 使用本地 xmin/xmax | 事务 ID 不能跨实例比较 |
| Index Tuple | 仅索引本地行 | 无原生全局 B-tree |
| Snapshot | 由一个实例构造 | 无天然全局一致 Snapshot |
| Row Lock | 本地 Lock Manager 管理 | 无原生跨分片行锁 |
| WAL Record | 写入本地 WAL | 每分片独立 WAL |
| LSN | 本地 WAL 位置 | 不能跨独立集群比较 |
| Buffer | 本地 shared_buffers | 每个分片各自缓存 |
| OS Page Cache | 每台机器独立 | 热数据可能分散 |
| Sequence | 本地对象 | 可能产生跨分片重复值 |
| Unique Constraint | 本地表或分区树范围 | 不能自动保证全局唯一 |
| Prepared Transaction | 记录于本地实例 | 需外部 Coordinator 管理所有参与者 |
| Route Record | 不属于 PostgreSQL MVCC 全局事务 | 需要独立控制面和 Epoch |
| CDC Cursor | 源到目标的复制位置 | 每条迁移链独立 |
| Outbox Event | 本地事务的一部分 | 目标端 At-Least-Once 应用 |
| Virtual Bucket Map | 路由元数据 | 改映射前必须先移动数据 |
| Statistics | 每实例累计 | 需要统一标签和时间窗口聚合 |
8. 场景和选型决策
| 业务场景 | 推荐方案 | 不推荐方案 | 性能代价 | 一致性代价 | HA/运维代价 |
|---|---|---|---|---|---|
| 少数慢 SQL 占主要负载 | SQL、索引、统计信息优化 | 直接分片 | 索引维护增加 | 通常无 | 低 |
| 重复读取且允许短暂陈旧 | Cache-Aside | 所有读打 Primary | 缓存失效和空间 | 最终一致 | 缓存成为新组件 |
| Primary 被报表拖慢 | 专用读副本或分析库 | 增加写节点假象 | 复制和网络开销 | Replica Lag | 副本恢复和冲突 |
| 大量历史分区删除 | Range Partitioning | 每行 DELETE | 分区规划和索引 | 无额外跨节点一致性 | 分区生命周期管理 |
| OLTP 与 Analytics 相互干扰 | CDC 到分析库 | 在 Primary 跑全量分析 | CDC 写放大 | 最终一致 | 新故障域 |
| 大量小租户且事务租户内 | Tenant Sharding | 按时间 Shard | 路由开销 | 租户内强一致 | 租户迁移 |
| 单个租户占主要流量 | 专用分片或二级 Shard Key | 单纯增加虚拟 Bucket | 路由和聚合增加 | 可能需要跨子分片 | 定制迁移 |
| 时间范围查询为主 | 时间分区;必要时按实体分片 | 时间 Hash Sharding | 分区管理 | 通常无 | 生命周期自动化 |
| 全局报表 | 汇总表、分析系统 | 在线 Fan-Out 所有分片 | ETL/CDC 成本 | 有刷新延迟 | 数据管道 |
| 高频跨租户事务 | 重构边界或集中该数据 | 大量 2PC | 协调和锁开销 | 可强一致但可用性下降 | 很高 |
| 少量跨分片工作流 | Saga/Outbox | 直接双写 | 事件和补偿开销 | 最终一致 | 中 |
| 需要数据库级分片能力 | 评估 Citus 等方案 | 自研全部分布式 SQL | 扩展调度开销 | 依实现而定 | Coordinator/Worker 运维 |
9. 高性能分析
9.1 各资源的水平扩展效果
| 资源 | 分片后的收益 | 新增代价 |
|---|---|---|
| CPU | 每个分片独立执行和规划 | Router、序列化、合并消耗 |
| 内存 | 每个节点拥有独立 Buffer 和 Page Cache | 相同参考数据、Catalog 和连接重复占用 |
| 随机 I/O | 热数据分散到多设备 | Fan-Out 增加总随机读 |
| 顺序 I/O | Backfill 和扫描可跨节点并行 | 网络汇总和尾延迟 |
| WAL | 每个分片独立生成和 Flush | 总 WAL、复制和归档带宽增加 |
| Checkpoint | 写入分散 | 所有分片同时 Checkpoint 可能形成集群级峰值 |
| Vacuum | 每个分片处理更小数据集 | 需要监控更多实例 |
| Temporary File | 分片本地排序可减少单点压力 | Router 可能再次排序 |
| 网络 | 本地事务网络较少 | 跨分片查询和复制显著增加 |
| 存储空间 | 单节点数据集缩小 | Replica、Outbox、双份迁移数据增加 |
9.2 放大效应
读放大
全局查询读放大
≈ 分片数 × 每分片扫描数据
写放大
业务写
+ 索引
+ WAL
+ Replica
+ Outbox
+ CDC
+ Cache Invalidation
+ 迁移期间目标副本
空间放大
Primary 数据
+ Secondary Index
+ TOAST
+ Replica
+ Backup
+ WAL Archive
+ Materialized View
+ Resharding 双份数据
9.3 性能实验记录要求
任何分片或扩容结论必须记录:
PostgreSQL 版本:
配置差异:
硬件:
分片数:
复制因子:
数据量:
平均/P99 行宽:
数据分布:
热租户占比:
缓存状态:
并发数:
Pool 大小:
测试时长:
P50/P95/P99:
TPS/QPS:
错误率:
Pool Acquire Wait:
Buffers:
WAL bytes/s:
CPU:
磁盘 IOPS/带宽/延迟:
Wait Event:
Checkpoint:
Vacuum:
Replica/CDC Lag:
不能把单次短压测的平均 TPS 当作容量上限。
10. 高并发分析
10.1 五个容易混淆的指标
| 指标 | 含义 |
|---|---|
| 应用 goroutine 数 | 应用中准备或等待执行的并发任务 |
| 连接数 | 已建立到 PostgreSQL 的 Backend 数 |
| 活跃查询数 | 当前实际执行 SQL 的 Backend 数 |
| TPS | 单位时间完成的事务数 |
| 排队请求数 | 等待 Semaphore、Pool 或数据库资源的请求数 |
例如:
10,000 goroutine
100 个 Pool 连接
40 个活跃 SQL
2,000 TPS
9,700 个请求在应用中排队
它们完全可以同时成立。
10.2 每分片连接池预算
对每个物理 PostgreSQL 集群分别计算:
应用实例数 × 该分片每实例 MaxConns
+ 后台任务连接
+ 管理连接
+ 迁移连接
+ 监控连接
≤ 可供业务使用的数据库连接数
如果多个逻辑分片只是同一 PostgreSQL 实例中的不同 Database,则必须把这些 Pool 加总,因为它们共享同一个 max_connections。
pgxpool.Stat() 能提供 AcquireDuration、AcquiredConns、EmptyAcquireCount、EmptyAcquireWaitTime、CanceledAcquireCount、MaxConns 等指标。(Go Packages)
10.3 Backpressure
推荐顺序:
HTTP/RPC Admission
→ 单租户限流
→ 全局请求 Semaphore
→ Fan-Out Semaphore
→ pgxpool
→ PostgreSQL
请求必须在进入数据库前拥有:
- Deadline;
- 最大 Fan-Out;
- 最大结果集;
- 最大重试次数;
- 幂等键;
- 优先级或工作负载类别。
10.4 重试风暴
只有明确可重试的完整事务才重试,例如:
40001:Serialization Failure;40P01:Deadlock Detected。
必须:
- 重试完整事务;
- 指数退避;
- 随机抖动;
- 最大次数;
- 服从 Context;
- 保持幂等;
- 限制总体重试预算。
Commit 返回网络错误时不能武断认为事务未提交,应通过幂等键查询最终状态。
10.5 热点不会因分片自动消失
以下语句即使位于独立分片,仍可能串行:
UPDATE tenant_balance
SET balance = balance - $1
WHERE tenant_id = $2;
如果所有请求都更新同一行,增加 Shard 数、连接数甚至 CPU 都不会解除该行锁竞争。
可选修复:
- 将不可交换业务改为预留和结算;
- 对可交换计数使用 Bucket;
- 缩短事务;
- 合并批量更新;
- 限制单租户并发;
- 将热点租户迁移到专用分片;
- 重新设计 Shard Key。
11. 高可用分析
| 主题 | 分片环境要求 |
|---|---|
| RPO | 每个 Shard 独立定义;异步 Replica 可能丢失已确认写入 |
| RTO | Router 识别新 Primary 的时间也是 RTO 的一部分 |
| 备份 | 必须覆盖所有 Shard、元数据服务和 Schema 版本 |
| PITR | 每个 Shard 有独立 Timeline 和恢复位置 |
| 物理复制 | 适合分片内部完整复制 |
| 逻辑复制 | 适合迁移、CDC、工作负载拆分 |
| 同步复制 | 降低 RPO,但增加提交延迟和不可用风险 |
| 异步复制 | 延迟较低,但 Failover 可能丢数据 |
| Planned Switchover | 先确认 Lag,再切拓扑和 Router |
| Unplanned Failover | 必须 Fence 旧 Primary |
| Failback | 应按重新加入和重新同步处理,不应直接反向切换 |
| 脑裂 | Router、代理和数据库层都必须拒绝旧 Primary |
| 应用重连 | 旧连接不会自动变成新拓扑连接 |
| Commit 不确定 | 使用幂等键和状态查询处理 |
| 元数据 HA | Route 更新需要强一致、CAS 和审计 |
| 恢复验证 | 恢复后必须验证业务不变量,而非只验证实例启动 |
读副本的 HA 和读扩展角色不能混为一谈:
- 一个副本可以为读取服务;
- 也可以作为 Failover 候选;
- 但长查询、复制延迟和恢复设置会影响它是否适合作为快速 Failover 候选。
12. 三维影响矩阵
| 维度 | 相关度 | 核心收益 | 主要风险 | 关键指标 |
|---|---|---|---|---|
| 高性能 | 高 | 增加总 CPU、内存、I/O 和缓存容量 | Fan-Out、网络、读写放大、尾延迟 | P95/P99、TPS、WAL/s、I/O、Shard Skew |
| 高并发 | 高 | 将独立键的竞争分散到多个写域 | Hot Key、Pool 爆炸、重试风暴、跨分片事务 | Active SQL、Pool Wait、Lock Wait、Retry Rate |
| 高可用 | 高 | 缩小单分片故障爆炸半径 | 故障域增多、元数据故障、全局恢复困难 | RPO、RTO、Lag、Failover Time、Route Epoch |
13. 实验
13.1 实验一:Tenant Sharding 与连接池预算
实验目标
- 使用两个 Database 模拟两个 Shard;
- 验证
tenant_id路由; - 验证一个租户不会创建一个独立 Pool;
- 观察 Pool 满时请求等待和超时;
- 验证一个 Shard 的 Pool 饱和不会自动耗尽另一个 Shard 的 Pool。
版本与扩展
- PostgreSQL 14~18;
- Go 和
pgx/v5; - 无必需扩展。
创建 Database
createdb shard_a
createdb shard_b
在两个 Database 中分别执行:
CREATE TABLE orders (
tenant_id bigint NOT NULL,
order_id uuid NOT NULL,
idempotency_key text NOT NULL,
amount_cents bigint NOT NULL CHECK (amount_cents >= 0),
route_version bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (tenant_id, order_id),
UNIQUE (tenant_id, idempotency_key)
);
CREATE TABLE shard_outbox (
event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id bigint NOT NULL,
order_id uuid NOT NULL,
idempotency_key text NOT NULL,
amount_cents bigint NOT NULL,
route_version bigint NOT NULL,
target_shard text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
lease_until timestamptz,
attempts integer NOT NULL DEFAULT 0,
published_at timestamptz,
UNIQUE (target_shard, tenant_id, idempotency_key)
);
CREATE INDEX shard_outbox_pending_idx
ON shard_outbox (created_at, event_id)
WHERE published_at IS NULL;
CREATE TABLE applied_events (
event_id uuid PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
Pool 配置
export DATABASE_URL='{
"shard-a-primary": {
"url": "postgres://app:[email protected]:5432/shard_a?sslmode=disable&application_name=router",
"max_conns": 2,
"role": "primary"
},
"shard-b-primary": {
"url": "postgres://app:[email protected]:5432/shard_b?sslmode=disable&application_name=router",
"max_conns": 2,
"role": "primary"
}
}'
Session A
通过应用 Pool 在 shard-a 执行:
SELECT pg_sleep(10);
该调用占用第一个 Pool 连接。
Session B
立即在同一个 Pool 执行:
SELECT pg_sleep(10);
该调用占用第二个连接。
Session C
使用 500ms Context 再向 shard-a 发起请求。
时间线
T0 Session A 获取连接
T0+50ms Session B 获取连接
T0+100ms Session C 请求第三个连接
T0+600ms Session C 因 Context Deadline 失败
T0+700ms 同一应用请求 shard-b,正常获取连接
T0+10s A/B 返回,连接回到 Pool
Session C 等待发生在应用 Pool,不会在 PostgreSQL 中出现第三个执行中的 SQL。
诊断 SQL
在 shard_a 中:
SELECT
application_name,
state,
wait_event_type,
wait_event,
count(*)
FROM pg_stat_activity
WHERE datname = 'shard_a'
GROUP BY
application_name,
state,
wait_event_type,
wait_event;
应用侧记录:
MaxConns
TotalConns
AcquiredConns
EmptyAcquireCount
EmptyAcquireWaitTime
CanceledAcquireCount
预期结果
shard-a的应用连接最多为 2;- 第三个请求在 Pool 中排队并因 Context 超时;
EmptyAcquireCount或CanceledAcquireCount上升;shard-b的独立 Pool 仍可工作;- 增加 goroutine 不会增加数据库连接上限,只会增加排队。
清理
dropdb shard_a
dropdb shard_b
生产安全警告
- 不要通过无限增加
MaxConns消除 Pool 等待; - 不要为每个租户创建 Pool;
- 不要在生产库使用
pg_sleep制造连接耗尽; - 管理和故障处理连接必须保留独立预算。
13.2 实验二:Hot Shard、虚拟分片和租户迁移
实验目标
比较四种路由方式:
- 按
tenant_id哈希; - 按
order_id哈希; tenant_id → virtual bucket → physical shard;- 将热租户迁移到专用分片。
准备数据
CREATE SCHEMA hotlab;
CREATE TABLE hotlab.workload AS
SELECT
CASE
WHEN g <= 60000 THEN 42
ELSE 100000 + g
END::bigint AS tenant_id,
gen_random_uuid() AS order_id
FROM generate_series(1, 100000) AS g;
ANALYZE hotlab.workload;
这里模拟租户 42 占 60% 请求。
比较路由分布
以下哈希函数只用于实验。生产 Router 应使用由应用明确版本化的稳定哈希,不能依赖数据库内部哈希结果永久不变。
WITH routed AS (
SELECT
'tenant_hash' AS strategy,
mod(
hashtextextended(tenant_id::text, 0)
& 9223372036854775807::bigint,
4::bigint
)::integer AS shard
FROM hotlab.workload
UNION ALL
SELECT
'order_hash',
mod(
hashtextextended(order_id::text, 0)
& 9223372036854775807::bigint,
4::bigint
)::integer
FROM hotlab.workload
UNION ALL
SELECT
'virtual_bucket',
mod(
mod(
hashtextextended(tenant_id::text, 0)
& 9223372036854775807::bigint,
128::bigint
),
4::bigint
)::integer
FROM hotlab.workload
UNION ALL
SELECT
'tenant_override',
CASE
WHEN tenant_id = 42 THEN 4
ELSE mod(
hashtextextended(tenant_id::text, 0)
& 9223372036854775807::bigint,
4::bigint
)::integer
END
FROM hotlab.workload
)
SELECT
strategy,
shard,
count(*) AS requests,
round(
100.0 * count(*) /
sum(count(*)) OVER (PARTITION BY strategy),
2
) AS request_percent
FROM routed
GROUP BY strategy, shard
ORDER BY strategy, shard;
预期解释
tenant_hash:租户 42 的全部请求落入一个 Shard;virtual_bucket:租户 42 仍然只有一个 Virtual Bucket,热点不消失;order_hash:请求更均匀,但租户查询和事务可能需要 Fan-Out;tenant_override:其他租户不再与 42 共享故障域,但 42 内部热点仍存在。
并发热点行
CREATE TABLE hotlab.tenant_counter (
tenant_id bigint PRIMARY KEY,
value bigint NOT NULL
);
INSERT INTO hotlab.tenant_counter VALUES (42, 0);
Session A
BEGIN;
UPDATE hotlab.tenant_counter
SET value = value + 1
WHERE tenant_id = 42;
SELECT pg_sleep(15);
COMMIT;
Session B
在 Session A 更新后立即执行:
BEGIN;
UPDATE hotlab.tenant_counter
SET value = value + 1
WHERE tenant_id = 42;
COMMIT;
Session B 会等待 Session A 的行锁。
Session C
SELECT
pid,
state,
wait_event_type,
wait_event,
pg_blocking_pids(pid) AS blockers,
query
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid();
Bucket 化对比
CREATE TABLE hotlab.tenant_counter_bucket (
tenant_id bigint NOT NULL,
bucket smallint NOT NULL,
value bigint NOT NULL,
PRIMARY KEY (tenant_id, bucket)
);
INSERT INTO hotlab.tenant_counter_bucket
SELECT 42, g, 0
FROM generate_series(0, 15) AS g;
Session A:
BEGIN;
UPDATE hotlab.tenant_counter_bucket
SET value = value + 1
WHERE tenant_id = 42
AND bucket = 0;
SELECT pg_sleep(15);
COMMIT;
Session B:
UPDATE hotlab.tenant_counter_bucket
SET value = value + 1
WHERE tenant_id = 42
AND bucket = 1;
第二条更新不再等待第一行的行锁。
读取总值:
SELECT sum(value)
FROM hotlab.tenant_counter_bucket
WHERE tenant_id = 42;
EXPLAIN
以下 UPDATE 会真实执行,因此必须在可丢弃事务中测试:
BEGIN;
EXPLAIN (
ANALYZE,
BUFFERS,
WAL,
SETTINGS,
VERBOSE,
SUMMARY
)
UPDATE hotlab.tenant_counter_bucket
SET value = value + 1
WHERE tenant_id = 42
AND bucket = 3;
ROLLBACK;
即使事务回滚,Sequence、外部调用或某些触发器副作用也未必完全可逆。
性能记录
使用 pgbench --log 或应用直方图记录:
- 并发数;
- 测试时长;
- P50/P95/P99;
- TPS;
- Lock Wait;
- WAL bytes;
- CPU;
- Pool Wait;
- 每分片请求占比。
禁止写入没有实际测量得到的固定性能数字。
清理
DROP SCHEMA hotlab CASCADE;
13.3 实验三:Resharding 的 Backfill、CDC、校验和切流
实验目标
在一个 Database 中使用不同 Schema 模拟两个 Shard,复现:
- 源分片权威写入;
- Backfill;
- Outbox CDC;
- 幂等应用;
- 数据校验;
- Fencing;
- Cutover;
- Rollback 边界。
同一 Database 中的 Schema 不提供真实故障隔离,本实验只用于展示状态机。
准备结构
CREATE SCHEMA control;
CREATE SCHEMA old_shard;
CREATE SCHEMA new_shard;
CREATE TABLE control.tenant_route (
tenant_id bigint PRIMARY KEY,
source_shard text NOT NULL,
target_shard text,
state text NOT NULL CHECK (
state IN (
'HOME_OLD',
'COPYING',
'CATCHING_UP',
'VALIDATING',
'FENCING',
'HOME_NEW'
)
),
route_epoch bigint NOT NULL,
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE old_shard.orders (
tenant_id bigint NOT NULL,
order_id uuid NOT NULL,
amount_cents bigint NOT NULL,
row_version bigint NOT NULL,
updated_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, order_id)
);
CREATE TABLE old_shard.outbox (
event_id uuid PRIMARY KEY,
tenant_id bigint NOT NULL,
order_id uuid NOT NULL,
amount_cents bigint NOT NULL,
row_version bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
applied_at timestamptz
);
CREATE INDEX old_outbox_pending_idx
ON old_shard.outbox (created_at, event_id)
WHERE applied_at IS NULL;
CREATE TABLE new_shard.orders (
tenant_id bigint NOT NULL,
order_id uuid NOT NULL,
amount_cents bigint NOT NULL,
row_version bigint NOT NULL,
updated_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, order_id)
);
CREATE TABLE new_shard.applied_events (
event_id uuid PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
INSERT INTO control.tenant_route (
tenant_id,
source_shard,
target_shard,
state,
route_epoch
)
VALUES (42, 'old_shard', 'new_shard', 'COPYING', 1);
INSERT INTO old_shard.orders
SELECT
42,
gen_random_uuid(),
g * 100,
1,
clock_timestamp()
FROM generate_series(1, 10000) AS g;
Session A:源分片继续写入
BEGIN;
WITH route AS (
SELECT route_epoch
FROM control.tenant_route
WHERE tenant_id = 42
AND source_shard = 'old_shard'
AND state IN ('COPYING', 'CATCHING_UP', 'VALIDATING')
FOR SHARE
),
changed AS (
INSERT INTO old_shard.orders AS o (
tenant_id,
order_id,
amount_cents,
row_version,
updated_at
)
SELECT
42,
$1::uuid,
$2::bigint,
1,
clock_timestamp()
FROM route
ON CONFLICT (tenant_id, order_id)
DO UPDATE
SET amount_cents = EXCLUDED.amount_cents,
row_version = o.row_version + 1,
updated_at = clock_timestamp()
RETURNING
tenant_id,
order_id,
amount_cents,
row_version,
updated_at
)
INSERT INTO old_shard.outbox (
event_id,
tenant_id,
order_id,
amount_cents,
row_version
)
SELECT
gen_random_uuid(),
tenant_id,
order_id,
amount_cents,
row_version
FROM changed;
COMMIT;
业务行和 Outbox 在同一事务中提交。
Session B:分批 Backfill
WITH batch AS (
SELECT
tenant_id,
order_id,
amount_cents,
row_version,
updated_at
FROM old_shard.orders
WHERE tenant_id = 42
AND order_id > $1::uuid
ORDER BY order_id
LIMIT 1000
)
INSERT INTO new_shard.orders AS target (
tenant_id,
order_id,
amount_cents,
row_version,
updated_at
)
SELECT
tenant_id,
order_id,
amount_cents,
row_version,
updated_at
FROM batch
ON CONFLICT (tenant_id, order_id)
DO UPDATE
SET amount_cents = EXCLUDED.amount_cents,
row_version = EXCLUDED.row_version,
updated_at = EXCLUDED.updated_at
WHERE target.row_version < EXCLUDED.row_version;
不断更新 $1 为上一批最后一个 order_id,直到影响行数为 0。
row_version 防止较旧的 Backfill 覆盖较新的 CDC 结果。
Session C:应用 Outbox
BEGIN;
WITH picked AS (
SELECT
event_id,
tenant_id,
order_id,
amount_cents,
row_version
FROM old_shard.outbox
WHERE applied_at IS NULL
ORDER BY created_at, event_id
FOR UPDATE SKIP LOCKED
LIMIT 100
),
accepted AS (
INSERT INTO new_shard.applied_events (event_id)
SELECT event_id
FROM picked
ON CONFLICT DO NOTHING
RETURNING event_id
),
upserted AS (
INSERT INTO new_shard.orders AS target (
tenant_id,
order_id,
amount_cents,
row_version,
updated_at
)
SELECT
p.tenant_id,
p.order_id,
p.amount_cents,
p.row_version,
clock_timestamp()
FROM picked AS p
JOIN accepted AS a USING (event_id)
ON CONFLICT (tenant_id, order_id)
DO UPDATE
SET amount_cents = EXCLUDED.amount_cents,
row_version = EXCLUDED.row_version,
updated_at = EXCLUDED.updated_at
WHERE target.row_version < EXCLUDED.row_version
RETURNING 1
)
UPDATE old_shard.outbox AS o
SET applied_at = clock_timestamp()
FROM picked AS p
WHERE o.event_id = p.event_id;
COMMIT;
applied_events 使事件可以安全重放。
等待条件
SELECT count(*) AS pending_events
FROM old_shard.outbox
WHERE applied_at IS NULL;
进入 Cutover 前必须为 0,或符合明确、可解释的迁移水位。
分 Bucket 校验
WITH source AS (
SELECT
mod(
hashtextextended(order_id::text, 0)
& 9223372036854775807::bigint,
64::bigint
) AS bucket,
count(*) AS row_count,
sum(amount_cents) AS amount_sum,
sum(row_version) AS version_sum
FROM old_shard.orders
WHERE tenant_id = 42
GROUP BY 1
),
target AS (
SELECT
mod(
hashtextextended(order_id::text, 0)
& 9223372036854775807::bigint,
64::bigint
) AS bucket,
count(*) AS row_count,
sum(amount_cents) AS amount_sum,
sum(row_version) AS version_sum
FROM new_shard.orders
WHERE tenant_id = 42
GROUP BY 1
)
SELECT *
FROM source
FULL JOIN target USING (bucket)
WHERE source.row_count IS DISTINCT FROM target.row_count
OR source.amount_sum IS DISTINCT FROM target.amount_sum
OR source.version_sum IS DISTINCT FROM target.version_sum;
最终 Fencing 后做精确差异检查:
SELECT count(*) AS differing_rows
FROM (
(
SELECT tenant_id, order_id, amount_cents, row_version
FROM old_shard.orders
WHERE tenant_id = 42
EXCEPT
SELECT tenant_id, order_id, amount_cents, row_version
FROM new_shard.orders
WHERE tenant_id = 42
)
UNION ALL
(
SELECT tenant_id, order_id, amount_cents, row_version
FROM new_shard.orders
WHERE tenant_id = 42
EXCEPT
SELECT tenant_id, order_id, amount_cents, row_version
FROM old_shard.orders
WHERE tenant_id = 42
)
) AS differences;
Fencing
UPDATE control.tenant_route
SET state = 'FENCING',
route_epoch = route_epoch + 1,
updated_at = clock_timestamp()
WHERE tenant_id = 42
AND state = 'VALIDATING'
AND route_epoch = 1;
此时:
- 拒绝持有旧 Epoch 的写入;
- 等待在途源事务结束;
- 重放最终 Outbox;
- 再做精确校验。
Cutover
UPDATE control.tenant_route
SET source_shard = 'new_shard',
target_shard = NULL,
state = 'HOME_NEW',
route_epoch = route_epoch + 1,
updated_at = clock_timestamp()
WHERE tenant_id = 42
AND state = 'FENCING'
AND route_epoch = 2;
Rollback 边界
COPYING、CATCHING_UP、VALIDATING:停止迁移并恢复旧路由通常较简单。FENCING且目标未接收新业务写入:可恢复旧路由。HOME_NEW且目标已接收新写入:必须先反向同步,不能只修改路由表。
删除事件的生产要求
本实验只演示 Upsert。生产系统还必须:
- 为 DELETE 产生 Tombstone;
- Tombstone 携带版本;
- 防止较旧 Backfill 复活已删除数据;
- 为 Schema 变更建立兼容窗口。
清理
DROP SCHEMA control CASCADE;
DROP SCHEMA old_shard CASCADE;
DROP SCHEMA new_shard CASCADE;
14. Go:有界 Shard Router
以下示例具备:
DATABASE_URL环境变量;- 每 Endpoint 一个
pgxpool; - Pool 总数上限;
- Virtual Bucket;
- Tenant Override;
- Primary/Replica 角色;
- Read Fallback;
- 有界 Fan-Out;
- 部分 Shard 失败;
- 事务完整重试;
40001、40P01分类;- Commit 结果不确定;
- 幂等写入;
- Outbox 和目标去重;
- Resharding Shadow Route;
- Pool 指标;
- Context 超时;
- 优雅停机。
配置格式:
export DATABASE_URL='{
"shard-a-primary": {
"url": "postgres://app:secret@db-a-primary/shard_a",
"max_conns": 8,
"role": "primary"
},
"shard-a-replica": {
"url": "postgres://app:secret@db-a-replica/shard_a",
"max_conns": 8,
"role": "replica"
},
"shard-b-primary": {
"url": "postgres://app:secret@db-b-primary/shard_b",
"max_conns": 8,
"role": "primary"
}
}'
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"log"
"math/rand"
"os"
"os/signal"
"sort"
"sync"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Endpoint struct {
URL string `json:"url"`
MaxConns int32 `json:"max_conns"`
Role string `json:"role"` // primary(默认)或 replica
}
type Route struct {
Write string
Reads []string
Shadow string // 只写源分片;Shadow 由 Outbox/CDC 填充
Version int64
}
type Order struct {
TenantID int64
OrderID string
IdempotencyKey string
AmountCents int64
RouteVersion int64
CreatedAt time.Time
}
type Event struct {
EventID string
TenantID int64
OrderID string
IdempotencyKey string
AmountCents int64
RouteVersion int64
Target string
}
type Router struct {
mu sync.RWMutex
pools map[string]*pgxpool.Pool
roles map[string]string
buckets []string
overrides map[int64]Route
fanoutSem chan struct{}
}
type CommitUnknownError struct{ Err error }
func (e *CommitUnknownError) Error() string {
return "commit result is unknown: " + e.Err.Error()
}
func (e *CommitUnknownError) Unwrap() error {
return e.Err
}
func NewRouter(
ctx context.Context,
maxPools int,
bucketCount int,
fanoutLimit int,
) (*Router, error) {
var specs map[string]Endpoint
if err := json.Unmarshal(
[]byte(os.Getenv("DATABASE_URL")),
&specs,
); err != nil {
return nil, fmt.Errorf("parse DATABASE_URL JSON: %w", err)
}
if len(specs) == 0 ||
len(specs) > maxPools ||
bucketCount < 1 ||
fanoutLimit < 1 {
return nil, errors.New(
"invalid endpoint, bucket, or fan-out limit",
)
}
all := make([]string, 0, len(specs))
primaries := make([]string, 0, len(specs))
for name, spec := range specs {
all = append(all, name)
if spec.Role == "" || spec.Role == "primary" {
primaries = append(primaries, name)
}
}
sort.Strings(all)
sort.Strings(primaries)
if len(primaries) == 0 {
return nil, errors.New("no primary endpoint")
}
r := &Router{
pools: make(map[string]*pgxpool.Pool, len(specs)),
roles: make(map[string]string, len(specs)),
buckets: make([]string, bucketCount),
overrides: make(map[int64]Route),
fanoutSem: make(chan struct{}, fanoutLimit),
}
for _, name := range all {
spec := specs[name]
role := spec.Role
if role == "" {
role = "primary"
}
if spec.URL == "" ||
spec.MaxConns < 1 ||
(role != "primary" && role != "replica") {
r.Close()
return nil, fmt.Errorf(
"invalid endpoint %q",
name,
)
}
cfg, err := pgxpool.ParseConfig(spec.URL)
if err != nil {
r.Close()
return nil, fmt.Errorf(
"parse endpoint %s: %w",
name,
err,
)
}
cfg.MaxConns = spec.MaxConns
cfg.MinConns = 0
cfg.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
r.Close()
return nil, fmt.Errorf(
"create pool %s: %w",
name,
err,
)
}
pingCtx, cancel := context.WithTimeout(
ctx,
2*time.Second,
)
err = pool.Ping(pingCtx)
cancel()
if err != nil {
pool.Close()
r.Close()
return nil, fmt.Errorf(
"ping %s: %w",
name,
err,
)
}
r.pools[name] = pool
r.roles[name] = role
}
for i := range r.buckets {
r.buckets[i] = primaries[i%len(primaries)]
}
return r, nil
}
func (r *Router) Close() {
if r == nil {
return
}
for _, pool := range r.pools {
pool.Close()
}
}
func bucket(tenantID int64, count int) int {
h := fnv.New64a()
_, _ = fmt.Fprintf(h, "%d", tenantID)
return int(h.Sum64() % uint64(count))
}
func (r *Router) RouteFor(tenantID int64) Route {
r.mu.RLock()
defer r.mu.RUnlock()
if route, ok := r.overrides[tenantID]; ok {
return route
}
name := r.buckets[bucket(
tenantID,
len(r.buckets),
)]
return Route{
Write: name,
Reads: []string{name},
Version: 1,
}
}
func (r *Router) SetRoute(
tenantID int64,
next Route,
) error {
r.mu.Lock()
defer r.mu.Unlock()
if current, ok := r.overrides[tenantID]; ok &&
next.Version <= current.Version {
return fmt.Errorf(
"route version must increase: %d <= %d",
next.Version,
current.Version,
)
}
if r.roles[next.Write] != "primary" {
return fmt.Errorf(
"write endpoint %q is not a primary",
next.Write,
)
}
for _, name := range next.Reads {
if r.pools[name] == nil {
return fmt.Errorf(
"unknown read endpoint %q",
name,
)
}
}
if next.Shadow != "" &&
r.roles[next.Shadow] != "primary" {
return fmt.Errorf(
"shadow endpoint %q is not a primary",
next.Shadow,
)
}
r.overrides[tenantID] = next
return nil
}
func retryable(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) &&
(pgErr.Code == "40001" ||
pgErr.Code == "40P01")
}
func (r *Router) CreateOrder(
ctx context.Context,
order Order,
) error {
const maxAttempts = 4
for attempt := 0; attempt < maxAttempts; attempt++ {
err := r.createOrderOnce(ctx, order)
if err == nil {
return nil
}
var unknown *CommitUnknownError
if errors.As(err, &unknown) ||
!retryable(err) ||
attempt == maxAttempts-1 {
return err
}
base := 25 *
time.Millisecond *
time.Duration(1<<attempt)
jitter := time.Duration(
rand.Int63n(int64(base/2) + 1),
)
timer := time.NewTimer(base + jitter)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return errors.New("unreachable")
}
func (r *Router) createOrderOnce(
ctx context.Context,
order Order,
) error {
route := r.RouteFor(order.TenantID)
queryCtx, cancel := context.WithTimeout(
ctx,
2*time.Second,
)
defer cancel()
tx, err := r.pools[route.Write].BeginTx(
queryCtx,
pgx.TxOptions{},
)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback(queryCtx)
}()
tag, err := tx.Exec(queryCtx, `
INSERT INTO orders
(
tenant_id,
order_id,
idempotency_key,
amount_cents,
route_version
)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (
tenant_id,
idempotency_key
)
DO NOTHING`,
order.TenantID,
order.OrderID,
order.IdempotencyKey,
order.AmountCents,
route.Version,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
var existingID string
var existingAmount int64
err = tx.QueryRow(queryCtx, `
SELECT
order_id::text,
amount_cents
FROM orders
WHERE tenant_id = $1
AND idempotency_key = $2`,
order.TenantID,
order.IdempotencyKey,
).Scan(
&existingID,
&existingAmount,
)
if err != nil {
return err
}
if existingID != order.OrderID ||
existingAmount != order.AmountCents {
return errors.New(
"idempotency key reused " +
"with different payload",
)
}
}
if route.Shadow != "" {
_, err = tx.Exec(queryCtx, `
INSERT INTO shard_outbox
(
tenant_id,
order_id,
idempotency_key,
amount_cents,
route_version,
target_shard
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (
target_shard,
tenant_id,
idempotency_key
)
DO NOTHING`,
order.TenantID,
order.OrderID,
order.IdempotencyKey,
order.AmountCents,
route.Version,
route.Shadow,
)
if err != nil {
return err
}
}
if err = tx.Commit(queryCtx); err != nil {
if retryable(err) {
return err
}
return &CommitUnknownError{Err: err}
}
return nil
}
func (r *Router) ReadOrders(
ctx context.Context,
tenantID int64,
limit int,
) ([]Order, error) {
if limit < 1 || limit > 1000 {
return nil, errors.New("invalid limit")
}
var lastErr error
for _, endpoint := range r.RouteFor(tenantID).Reads {
queryCtx, cancel := context.WithTimeout(
ctx,
800*time.Millisecond,
)
rows, err := r.pools[endpoint].Query(
queryCtx,
`
SELECT
tenant_id,
order_id::text,
idempotency_key,
amount_cents,
route_version,
created_at
FROM orders
WHERE tenant_id = $1
ORDER BY
created_at DESC,
order_id DESC
LIMIT $2`,
tenantID,
limit,
)
if err != nil {
cancel()
lastErr = err
continue
}
orders := make([]Order, 0, limit)
for rows.Next() {
var order Order
if err := rows.Scan(
&order.TenantID,
&order.OrderID,
&order.IdempotencyKey,
&order.AmountCents,
&order.RouteVersion,
&order.CreatedAt,
); err != nil {
rows.Close()
cancel()
return nil, err
}
orders = append(orders, order)
}
err = rows.Err()
rows.Close()
cancel()
if err == nil {
return orders, nil
}
lastErr = err
}
return nil, fmt.Errorf(
"all read endpoints failed: %w",
lastErr,
)
}
type FanoutResult struct {
CountByShard map[string]int64
Failures map[string]error
}
func (r *Router) FanoutCount(
ctx context.Context,
since time.Time,
) FanoutResult {
type item struct {
name string
count int64
err error
}
r.mu.RLock()
names := make([]string, 0, len(r.pools))
for name, role := range r.roles {
if role == "primary" {
names = append(names, name)
}
}
r.mu.RUnlock()
sort.Strings(names)
results := make(chan item, len(names))
var wg sync.WaitGroup
for _, name := range names {
name := name
wg.Add(1)
// Goroutine 数量受 maxPools 上限约束。
go func() {
defer wg.Done()
select {
case r.fanoutSem <- struct{}{}:
defer func() {
<-r.fanoutSem
}()
case <-ctx.Done():
results <- item{
name: name,
err: ctx.Err(),
}
return
}
queryCtx, cancel := context.WithTimeout(
ctx,
time.Second,
)
defer cancel()
var count int64
err := r.pools[name].QueryRow(
queryCtx,
`
SELECT count(*)
FROM orders
WHERE created_at >= $1`,
since,
).Scan(&count)
results <- item{
name: name,
count: count,
err: err,
}
}()
}
wg.Wait()
close(results)
result := FanoutResult{
CountByShard: map[string]int64{},
Failures: map[string]error{},
}
for current := range results {
if current.err != nil {
result.Failures[current.name] =
current.err
continue
}
result.CountByShard[current.name] =
current.count
}
return result
}
func (r *Router) LeaseEvent(
ctx context.Context,
source string,
) (Event, error) {
var event Event
queryCtx, cancel := context.WithTimeout(
ctx,
time.Second,
)
defer cancel()
err := r.pools[source].QueryRow(queryCtx, `
WITH picked AS (
SELECT event_id
FROM shard_outbox
WHERE published_at IS NULL
AND (
lease_until IS NULL
OR lease_until < clock_timestamp()
)
ORDER BY created_at, event_id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE shard_outbox AS o
SET lease_until =
clock_timestamp() + interval '30 seconds',
attempts = attempts + 1
FROM picked
WHERE o.event_id = picked.event_id
RETURNING
o.event_id::text,
o.tenant_id,
o.order_id::text,
o.idempotency_key,
o.amount_cents,
o.route_version,
o.target_shard`,
).Scan(
&event.EventID,
&event.TenantID,
&event.OrderID,
&event.IdempotencyKey,
&event.AmountCents,
&event.RouteVersion,
&event.Target,
)
return event, err
}
func (r *Router) ApplyEvent(
ctx context.Context,
source string,
event Event,
) error {
target := r.pools[event.Target]
if target == nil {
return fmt.Errorf(
"unknown target %q",
event.Target,
)
}
queryCtx, cancel := context.WithTimeout(
ctx,
2*time.Second,
)
defer cancel()
tx, err := target.BeginTx(
queryCtx,
pgx.TxOptions{},
)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback(queryCtx)
}()
tag, err := tx.Exec(queryCtx, `
INSERT INTO applied_events (event_id)
VALUES ($1)
ON CONFLICT DO NOTHING`,
event.EventID,
)
if err != nil {
return err
}
if tag.RowsAffected() == 1 {
_, err = tx.Exec(queryCtx, `
INSERT INTO orders AS o
(
tenant_id,
order_id,
idempotency_key,
amount_cents,
route_version
)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (
tenant_id,
idempotency_key
)
DO UPDATE
SET amount_cents =
EXCLUDED.amount_cents,
route_version =
EXCLUDED.route_version
WHERE
o.route_version
<= EXCLUDED.route_version`,
event.TenantID,
event.OrderID,
event.IdempotencyKey,
event.AmountCents,
event.RouteVersion,
)
if err != nil {
return err
}
}
if err = tx.Commit(queryCtx); err != nil {
return &CommitUnknownError{Err: err}
}
markCtx, markCancel := context.WithTimeout(
ctx,
time.Second,
)
defer markCancel()
_, err = r.pools[source].Exec(markCtx, `
UPDATE shard_outbox
SET published_at = clock_timestamp(),
lease_until = NULL
WHERE event_id = $1`,
event.EventID,
)
// 标记失败时可以重新租约和重放。
// Target 端通过 applied_events 保证幂等。
return err
}
func (r *Router) PoolStats() map[string]map[string]int64 {
result := make(
map[string]map[string]int64,
len(r.pools),
)
for name, pool := range r.pools {
stats := pool.Stat()
result[name] = map[string]int64{
"max":
int64(stats.MaxConns()),
"total":
int64(stats.TotalConns()),
"acquired":
int64(stats.AcquiredConns()),
"idle":
int64(stats.IdleConns()),
"acquire_ms":
stats.AcquireDuration().Milliseconds(),
"empty_acquire_count":
stats.EmptyAcquireCount(),
"empty_acquire_wait_ms":
stats.EmptyAcquireWaitTime().
Milliseconds(),
"canceled_acquires":
stats.CanceledAcquireCount(),
}
}
return result
}
func run(ctx context.Context) error {
router, err := NewRouter(
ctx,
16, // 所有 Primary/Replica Pool 总上限
256, // Virtual Bucket
8, // 全局 Fan-Out 并发上限
)
if err != nil {
return err
}
defer router.Close()
// Catch-Up 阶段:
// shard-a-primary 仍是唯一写入权威;
// shard-b-primary 仅由 Outbox/CDC 填充。
if err := router.SetRoute(42, Route{
Write: "shard-a-primary",
Reads: []string{
"shard-a-replica",
"shard-a-primary",
},
Shadow: "shard-b-primary",
Version: 2,
}); err != nil {
return err
}
result := router.FanoutCount(
ctx,
time.Now().Add(-24*time.Hour),
)
log.Printf(
"counts=%v partial_failures=%v pools=%v",
result.CountByShard,
result.Failures,
router.PoolStats(),
)
return nil
}
func main() {
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
syscall.SIGTERM,
)
defer stop()
if err := run(ctx); err != nil {
log.Fatal(err)
}
}
该示例中的路由表是进程内存结构。生产环境必须把路由元数据放入高可用控制面,并使用 CAS、审计和 Route Epoch。修改 Virtual Bucket 映射之前,必须先完成数据迁移和校验,不能只修改数组。
15. 生产排障 Runbook
15.1 首先确认什么
- SLO 从什么时间开始违约;
- 只有一个租户、一个 Shard 还是全局异常;
- 最近是否发生发布、DDL、Failover、Resharding 或批处理;
- 是读、写、Fan-Out 还是 Metadata 请求异常;
- 错误是超时、连接获取失败、SQLSTATE、复制延迟还是部分 Shard 失败。
15.2 查看哪些指标
按 Shard、租户和 Endpoint 拆分:
- TPS/QPS;
- P50/P95/P99;
- 错误率;
- Pool Acquire Wait;
- Pool Canceled Acquire;
- Active/Idle Connections;
- CPU;
- 内存和 Swap;
- 数据盘和 WAL 盘延迟;
- WAL bytes/s;
- Checkpoint;
- Temporary Files;
- Lock Wait;
- Deadlock;
- Vacuum Debt;
- Replica Lag;
- CDC Lag;
- Fan-Out 宽度;
- 路由缓存命中率;
- Route Epoch 冲突;
- 每租户请求占比。
15.3 查询哪些系统视图
pg_stat_activity
pg_locks
pg_stat_database
pg_stat_statements
pg_stat_io
pg_stat_wal
pg_stat_checkpointer
pg_stat_user_tables
pg_stat_user_indexes
pg_stat_replication
pg_stat_database_conflicts
pg_prepared_xacts
15.4 如何找到 Blocker
SELECT
blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
blocked.wait_event_type,
blocked.wait_event,
blocked.query AS blocked_query,
blocker.pid AS blocker_pid,
blocker.state AS blocker_state,
blocker.xact_start AS blocker_xact_start,
blocker.query AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL
unnest(pg_blocking_pids(blocked.pid))
AS blocking_pid
JOIN pg_stat_activity AS blocker
ON blocker.pid = blocking_pid
ORDER BY blocker.xact_start;
15.5 如何找到最早的执行计划估算错误
- 从 SLO 开始恶化的最早时间定位
queryid; - 对比发布前后调用次数、平均时间、读块、临时文件和 WAL;
- 查看表数据分布或统计信息是否变化;
- 在副本或测试环境复现参数;
- 使用:
EXPLAIN (
ANALYZE,
BUFFERS,
WAL,
SETTINGS,
VERBOSE,
SUMMARY
)
SELECT ...;
比较每个节点的:
estimated rows
actual rows
loops
buffers
temporary I/O
15.6 判断资源类型
| 现象 | 优先判断 |
|---|---|
| CPU 高、无明显等待 | 执行计划、表达式、Join、并发过高 |
IO Wait 高 | 数据盘、WAL、Checkpoint、缓存失效 |
Lock Wait 高 | 热点行、DDL、长事务 |
| Pool Wait 高、DB Active 低 | Pool 太小、连接泄漏或慢外部调用占用事务 |
| DB Active 高、CPU 低 | 锁、I/O、同步复制或客户端写阻塞 |
| WAL/s 激增 | 批量更新、索引、FPI、Backfill、重试 |
| Dead Tuple 增长 | Vacuum 落后、长事务、Replica Feedback |
| Replica Lag 增长 | 网络、WAL 生成、重放 I/O、Standby 查询冲突 |
| 单 Shard 异常 | Hot Tenant、数据倾斜、局部硬件或计划问题 |
| 所有 Shard 同时异常 | 应用发布、Metadata、共享网络、全局 Fan-Out |
15.7 可在线执行的低风险动作
- 暂停非关键批处理;
- 降低 Fan-Out 并发;
- 对热租户限流;
- 将强一致读取临时路由 Primary;
- 将可陈旧读取路由健康 Replica;
- 取消明确无用的长查询;
- 刷新 Router 路由缓存;
- 停止新的 Resharding;
- 临时增加应用层队列和超时保护;
- 在已有能力内垂直扩容。
15.8 高风险动作
- 大规模终止 Backend;
- 直接修改 Shard Mapping;
- 未 Fencing 就 Failover;
- 未校验就 Cutover;
VACUUM FULL;- 阻塞式
REINDEX; - 大表同步 DDL;
- 一次性 Backfill 整个租户;
- 随意增加
max_connections; - 随意改变同步复制语义;
- 清理仍在使用的复制槽;
- 删除旧 Shard 数据。
绝不能为了测试或止损随意关闭:
fsync;full_page_writes;- Autovacuum;
- 数据校验;
- 同步复制保护。
15.9 临时止损
优先级通常为:
停止放大源
→ 保护核心写入
→ 限制热租户
→ 暂停 Fan-Out/报表/迁移
→ 回退最近变更
→ 隔离 Hot Tenant
→ 扩容
15.10 根本修复
可能包括:
- 修复 SQL 和索引;
- 修改数据模型;
- 对热点计数 Bucket 化;
- 调整 Pool 和 Admission Control;
- 增加读副本;
- 拆分分析工作负载;
- 迁移热租户;
- 调整 Shard Key;
- 增加 Virtual Bucket;
- 重构跨分片事务;
- 修复 CDC 幂等和 Fencing。
15.11 验证修复
必须验证:
- P95/P99 恢复;
- 队列不再无界增长;
- Pool Wait 恢复;
- Hot Shard 利用率下降;
- 没有把问题转移到 WAL、Replica 或其他 Shard;
- 错误率和重试率下降;
- 数据校验通过;
- 在代表性峰值和 Failover 场景下仍保留余量。
15.12 告警
至少包括:
- 每 Shard P99;
- 每租户请求占比;
- Shard Utilization Skew;
- Pool Empty Acquire;
- Fan-Out 宽度;
- Partial Shard Failure;
- Route Epoch Rejection;
- Replica Lag;
- CDC Lag;
- Outbox Age;
- Prepared Transaction Age;
- WAL Archive Lag;
- Backup Age;
- Resharding Validation Failure。
16. 常见错误与反模式
- 仅根据表行数决定分片。
- 把读副本当成写扩展。
- 把 PostgreSQL 分区称为跨节点分片。
- 只按数据量而不按请求、WAL 和锁竞争选择 Shard Key。
- 假设哈希一定能消除大租户热点。
- 为每个租户创建独立
pgxpool。 - 应用实例扩容后不重新计算全局连接预算。
- 为每个 Fan-Out Shard 启动无界 goroutine。
- 全局分页使用大 OFFSET。
- 将 UUIDv7 当作严格全局事务顺序。
- 使用本地 Sequence 却假设跨分片唯一。
- 应用直接双写源和目标数据库。
- CDC 未追平、未校验就 Cutover。
- Cutover 后直接翻回旧路由进行 Rollback。
- 不使用 Route Epoch,允许陈旧 Router 写旧 Shard。
- 比较两个独立 PostgreSQL 集群的 LSN 大小。
- 在没有事务管理器时启用并长期保留 Prepared Transaction。
- 所有错误都自动重试。
- Commit 超时后直接重新创建业务对象。
- Fan-Out 遇到部分失败时静默返回不完整数据。
- 增加 Citus Worker 后假设旧 Shard 会自动均衡。
- 只备份数据 Shard,不备份路由元数据。
- 把 Replica 当作备份。
- 忽略迁移期间双份数据、WAL 和网络的容量需求。
17. 模拟生产事故案例
17.1 模拟生产案例一:哈希分片后仍然只有一个 Shard 过载
系统背景
- 8 个 Tenant Shard;
- 按
hash(tenant_id)路由; - 每个 Shard 有一个 Primary 和一个 Replica;
- 一个企业租户占总写入的 46%。
故障现象
- 集群平均 CPU 仅 35%;
- 某个 Shard CPU 达到 95%;
- 该 Shard Pool Acquire P99 急剧上升;
- 订单写入出现超时;
- 其他 7 个 Shard 基本空闲。
错误假设
“已经使用 Hash Sharding,所以请求会均匀分布。”
排查过程
- 按 Shard 查看 CPU 和 WAL;
- 确认只有 Shard 3 异常;
- 按
tenant_id聚合请求和 WAL; - 发现租户 42 占 Shard 3 的 82% 写入;
- 检查锁,发现该租户的每日计数行存在持续行锁等待;
- Virtual Bucket 映射显示该租户只占一个 Bucket。
根因
- 分片均衡的是键,不是键内部负载;
- 单租户是 Hot Key;
- 租户内部还有 Hot Row;
- 增加物理 Shard 无法拆开同一个 Tenant Key。
临时止损
- 对租户 42 启用独立限流;
- 暂停该租户的大型导入;
- 将租户迁移到专用 Shard;
- 将非关键读取转移 Replica;
- 暂停全局报表。
最终修复
- 热租户使用专用 Shard;
- 高频计数改为 Bucket;
- 部分实体按
(tenant_id, entity_id)二级分片; - 对跨子分片查询使用预聚合;
- 增加每租户容量配额和迁移自动化。
监控补充
- 每租户 QPS/TPS;
- 每租户 WAL;
- 每租户锁等待;
- 最大租户占 Shard 比例;
- Shard Skew;
- Virtual Bucket 热度。
防止复发
容量评审必须同时包含:
平均租户
P95 租户
最大租户
最大租户增长速度
最大单键写并发
17.2 模拟生产案例二:Resharding 直接双写导致数据分叉
系统背景
团队将租户 77 从 Shard A 迁移到 Shard B。
应用代码采用:
写 Shard A
写 Shard B
没有 Outbox、幂等事件、版本号和 Fencing。
故障现象
- Cutover 后部分订单在 B 中不存在;
- 部分订单金额不同;
- 应用日志只显示少量 Target Timeout;
- 重试后出现重复扣减。
错误假设
“写 B 超时就代表 B 没有提交,重试即可。”
排查过程
-
对比 A/B 行数;
-
按订单 ID 做精确差异;
-
发现三类状态:
- A 有、B 无;
- A/B 都有但版本不同;
- B 已提交但应用认为失败并重试;
-
检查日志,发现 Source Commit 成功后 Target Commit 超时;
-
无事件 ID,无法判断 Target 是否已应用;
-
Cutover 期间旧 Router 仍向 A 写入。
根因
- 直接双写不能原子提交;
- Commit 超时结果不确定;
- 无幂等键;
- 无唯一写入权威;
- 无 Route Epoch;
- 无最终校验。
临时止损
- 暂停租户 77 写入;
- 将权威路由恢复到 A;
- 从审计日志构造修复事件;
- 对 B 做幂等补偿;
- 精确校验后恢复服务。
最终修复
- 源业务事务原子写 Outbox;
- CDC 幂等应用 B;
- 使用
row_version; - 建立 Shadow Read;
- Cutover 前 Fencing;
- Cutover 后保持反向 CDC 窗口;
- Commit 不确定通过幂等键查询。
监控补充
- Outbox 最老事件年龄;
- CDC Lag;
- Duplicate Event;
- Version Conflict;
- Route Epoch Rejection;
- 每次迁移的 Bucket 校验结果。
防止复发
禁止上线不具备以下条件的迁移:
Single Writer
Idempotency
CDC Cursor
Validation
Fencing
Cutover CAS
Rollback Plan
18. 面试题
18.1 核心概念题
题 1:什么情况下应该开始分片?
30 秒回答: 只有在 SQL、索引、数据模型、垂直扩展、缓存、读副本、分区和工作负载拆分之后,仍能证明单 Primary 的写入、容量或故障域无法满足 SLO 时,才应分片。
深入回答: 证据应包含持续 P99、CPU、I/O、WAL、Pool Wait、Hot Key、Replica Lag 和容量余量。表行数不是充分条件。分片的收益是增加总资源并缩小局部故障域,代价是路由、Fan-Out、全局约束、跨分片事务和恢复复杂度。
考察: 是否会先证明瓶颈。 常见错误: “超过一亿行就分片。” 追问: CPU 95% 是否足以证明要分片? 答案: 不足。还需判断是否由低效 SQL、计划错误、无界并发或单热点造成。
题 2:分区与分片有什么区别?
30 秒回答: 分区是在同一 PostgreSQL 集群内把一个逻辑表拆成多个子表;分片是把数据分到多个独立数据库或节点。
深入回答: 分区共享 Primary、WAL、Buffer、连接和故障域,适合裁剪和生命周期管理。分片增加独立资源和写域,但丢失天然全局 Snapshot、锁、Sequence 和唯一约束。
考察: 是否理解故障域和写入域。 常见错误: “创建 Hash Partition 就是分库分表。” 追问: 分区能否提高写吞吐? 答案: 可能减少局部索引和维护成本,但不会新增跨机器 Primary 写能力。
题 3:为什么读副本不能扩展写入?
30 秒回答: Hot Standby 只重放 Primary 产生的 WAL,并接受只读查询;业务写仍在 Primary 执行和提交。
深入回答: 副本可以转移读取,从而间接释放 Primary CPU 和 I/O,但 Tuple、Index、Lock、WAL 和 Commit 仍由 Primary 完成。多主写入属于不同架构,需要冲突和全局事务语义。
考察: 是否混淆读扩展和写扩展。 常见错误: “一主三从就是四倍数据库能力。” 追问: 副本何时反而伤害 Primary? 答案: 复制槽、反馈、同步复制等待、WAL 保留和网络带宽可能增加 Primary 压力。
题 4:如何选择 Shard Key?
30 秒回答: 选择高频查询携带、事务可共置、稳定、高基数、按负载均匀且可迁移的键。
深入回答: 需分析最大租户而非平均租户,还要考虑地域合规、全局查询、未来增长和热点。Tenant ID 适合租户内事务,但对 Whale Tenant 需要专用 Shard 或二级键。
考察: 数据局部性与倾斜。 常见错误: 只看字段基数。 追问: 时间戳适合做 Hash Shard Key 吗? 答案: 通常不适合时间范围查询;时间更适合作为分区键,分片键常选租户或实体。
题 5:什么是 Hot Shard?
30 秒回答: 请求、CPU、I/O、WAL、锁或增长速度显著高于其他分片的物理 Shard。
深入回答: Hot Shard 不一定数据最多。原因可能是 Hot Tenant、Hot Row、索引页、慢 SQL 或硬件异常。Virtual Bucket 便于搬迁负载,但不能拆分单一 Hot Key。
考察: 是否按负载而非容量判断。 常见错误: “最大 Shard 就是 Hot Shard。” 追问: 第一止损措施是什么? 答案: 限制放大源、保护核心写入、识别热租户,再决定迁移或修改键。
18.2 原理与排障题
题 6:如何定义数据库可持续容量?
30 秒回答: 在代表性负载和数据分布下,经历 Checkpoint、Vacuum 和业务周期后,仍满足延迟、错误率、复制延迟和队列稳定性的最大持续负载。
深入回答: 需同时检查 CPU、I/O、WAL、连接、锁、Pool、热租户和恢复余量。瞬时峰值和平均 TPS 都不能代表可持续容量。
考察: 性能工程方法。 常见错误: 只看 CPU 或压测最高 TPS。 追问: 为什么队列稳定性重要? 答案: 到达率长期大于服务率时,即使短期错误率低,排队也会最终耗尽内存和超时预算。
题 7:Fan-Out 为什么容易恶化 P99?
30 秒回答: 整体请求通常要等待最慢分片,并且分片越多,遇到慢节点或失败节点的概率越大。
深入回答: 还会增加连接、网络、排序、内存和读取放大。应限制宽度和并发,设置每分片 Deadline,并为部分失败定义明确契约。
考察: Tail Latency。 常见错误: “并行就一定更快。” 追问: 什么时候可以返回部分结果? 答案: 只有 API 明确允许近似或不完整结果,并携带失败 Shard 和完整性标记时。
题 8:如何做跨分片分页?
30 秒回答:
使用 (sort_key, global_id) 的 Keyset Cursor,每个分片读取局部下一页,再在 Router 做 K 路归并。
深入回答: 全局 OFFSET 会让每个 Shard 扫描大量无用行,且并发写入下不稳定。迁移期间 Cursor 还应携带 Route Epoch,并按 Global ID 去重。
考察: 全序与分页稳定性。 常见错误: 每个 Shard 都执行相同 OFFSET。 追问: 为什么需要 Global ID Tie-Breaker? 答案: 排序键相同时,没有唯一 Tie-Breaker 就无法构造稳定全序。
题 9:2PC、Saga 和 Outbox 如何选?
30 秒回答: 高频事务优先保持单分片;必须原子提交且有可靠协调器时才考虑 2PC;长业务流程使用 Saga;跨系统事件传递使用 Outbox。
深入回答: 2PC 强一致但降低可用性并可能保留锁。Saga 最终一致且需要补偿。Outbox解决本地事务与事件发布原子性,但不提供瞬时跨系统原子可见性。
考察: 一致性与可用性权衡。 常见错误: “Outbox 等于分布式事务。” 追问: Prepared Transaction 泄漏有什么风险? 答案: 长期持锁、阻塞 Vacuum,并可能推动事务 ID 风险。
题 10:如何计算分片连接池预算?
30 秒回答: 按每个物理 PostgreSQL 实例计算:应用实例数乘每实例 Pool 上限,再加后台、监控、迁移和管理连接,不得超过可用连接预算。
深入回答: 应用水平扩容会成倍增加连接。多个 Database 位于同一 PostgreSQL 实例时必须合计。Pool Wait 比简单连接利用率更能反映排队。
考察: goroutine、Pool 和 Backend 的关系。 常见错误: 每个应用实例都把 Pool 设到数据库上限。 追问: Pool 越大 TPS 越高吗? 答案: 不一定;过多活跃查询会增加调度、内存、锁和缓存竞争。
题 11:Resharding 的最关键安全点是什么?
30 秒回答: 保持唯一写入权威、使用幂等 CDC、完成校验,并在 Cutover 前通过 Route Epoch Fencing 拒绝旧路由。
深入回答: 安全流程是 Backfill、Catch-Up、Validation、Fence、Final Delta、CAS Cutover、观察和 Finalize。直接双写会产生无法自动判定的部分提交。
考察: 在线迁移状态机。 常见错误: CDC Lag 为零就立即改路由。 追问: Cutover 后如何 Rollback? 答案: 先将目标新写反向同步到源,再 Fence 并切回;不能只改元数据。
18.3 架构设计题
题 12:设计全局 ID
30 秒回答: 根据索引局部性、信息暴露、生成依赖和路由需求,在 UUIDv4、UUIDv7、Snowflake 类 ID 或复合 ID 中选择。
深入回答: UUIDv7 大致按时间排序但不是 Commit 顺序;Snowflake 需要 Worker Lease 和时钟处理;复合 Shard ID 会暴露拓扑。全局 ID 与 Shard Key 应独立设计。
考察: 唯一性、顺序和路由的区别。 常见错误: 认为 UUID 自动解决分片。 追问: 如何保证用户邮箱全局唯一? 答案: 使用独立唯一性目录或 Home Shard,而不是依赖各 Shard 的本地 Unique Index。
题 13:设计多租户分片
30 秒回答: 以 Tenant ID 为一级路由,使用 Virtual Bucket 到物理 Shard,并为超大租户提供 Override 或二级分片。
深入回答: 租户内表必须共置,所有关键 SQL 应携带 Tenant ID。路由元数据使用 Epoch。热租户可以迁移到专用 Shard,跨租户报表通过 CDC 和汇总系统处理。
考察: 共置、热点和可迁移性。
常见错误: 只做 hash(tenant_id) % N。
追问: 扩容时为何不能直接改 N?
答案: 大量键会重新映射,导致数据位置和路由不一致;应使用 Virtual Bucket 或一致性迁移流程。
题 14:设计分片级高可用
30 秒回答: 每个 Shard 使用 Primary、Replica、备份和 PITR,并由高可用元数据服务向 Router 发布带 Epoch 的拓扑。
深入回答: Failover 必须 Fence 旧 Primary,Router 关闭旧连接并重连。每 Shard 独立定义 RPO/RTO。全局恢复需要 Backup Epoch 或业务一致性屏障。
考察: 数据面和控制面 HA。 常见错误: 只给数据 Shard 做 HA。 追问: Metadata Service 不可用怎么办? 答案: 已缓存稳定路由可短时继续;未知或迁移中的写入应拒绝,避免错误路由。
题 15:如何设计全局分析查询?
30 秒回答: 优先通过 Outbox/CDC 将各 Shard 数据汇入分析库或汇总表,而不是在线 Fan-Out 所有 OLTP Shard。
深入回答: 可分解指标在 Shard 内预聚合,再合并。精确 Distinct、Percentile 和复杂 Join 更适合分析系统。需要明确数据新鲜度、重放、去重和校验。
考察: OLTP 与 Analytics 隔离。 常见错误: 在用户请求路径实时扫描全部 Shard。 追问: 如何处理 CDC 重复事件? 答案: 使用稳定 Event ID、目标去重表和幂等 Upsert。
19. 练习与参考答案
19.1 理论题
1. 为什么“CPU 还有 50% 空闲”不能证明容量安全?
答案: 可能存在单 Shard、单 Core、WAL、I/O、锁、连接池或 Replica Lag 边界;平均 CPU 也可能掩盖热点。还要检查 P99 和排队是否随负载持续增长。
2. 为什么 Virtual Bucket 不能拆分一个 Hot Tenant?
答案:
当 Virtual Bucket 仍由 hash(tenant_id) 决定时,同一租户只有一个 Bucket。要拆分其内部负载,需要二级键、Bucket 化或业务拆分。
3. 为什么分片后不能直接使用全局 Sequence?
答案: 独立 PostgreSQL 实例的 Sequence 不协调,可能产生重复值。需要 UUID、Snowflake 类 ID、复合 ID 或中心分配服务。
4. 为什么跨分片 Snapshot 困难?
答案:
各实例拥有独立事务 ID、Snapshot、WAL 和 Commit 时间点。分别开启 REPEATABLE READ 只能获得每个实例各自一致的 Snapshot,不能天然保证同一全局业务时刻。
5. 为什么直接双写比 Outbox 风险高?
答案: 两个独立 Commit 不能由普通应用原子完成,网络超时还会产生 Commit 结果不确定。Outbox 将业务修改和待发送事件纳入一个本地事务。
19.2 实验题
1. 将实验一扩展为 4 个 Shard,并验证应用扩容后的连接预算
参考答案:
记录:
应用实例数 × 每 Shard MaxConns
在每次增加应用实例后查询 pg_stat_activity,并记录 pgxpool.Stat()。确认所有实例的 Pool 总和仍低于每个 PostgreSQL 集群的可用连接预算。
2. 将实验二中的热租户拆为 16 个实体 Bucket
参考答案:
使用:
bucket = stable_hash(entity_id) % 16
主键和路由包含:
tenant_id, bucket, entity_id
比较写等待和读取 Fan-Out。说明锁竞争下降的同时,租户级聚合成本上升。
3. 为实验三增加 DELETE Tombstone
参考答案:
Outbox 增加:
operation = DELETE
row_version
目标保留 Tombstone 或删除版本记录。Backfill Upsert 必须检查目标 Tombstone Version,不能用旧 Snapshot 复活数据。
19.3 排障题
1. 所有 Shard CPU 都不高,但 P99 和 Pool Wait 上升
参考答案:
依次检查:
- Pool 大小和应用实例数;
idle in transaction;- 应用是否在事务内调用外部服务;
- Lock Wait;
- 同步复制等待;
- Client Write;
- 请求是否在应用 Semaphore 前排队;
- Fan-Out 是否占用所有连接。
2. 新增 Shard 后总体性能变差
参考答案:
检查:
- 是否真的完成数据 Rebalance;
- Fan-Out 宽度是否增加;
- Router 网络路径;
- Pool 总数和连接预算;
- Metadata 查询;
- 跨 Shard Join;
- CDC 和复制带宽;
- 数据倾斜是否仍存在;
- 新节点硬件和配置是否一致。
19.4 系统设计题
题目
设计一个全球 SaaS 订单系统:
- 100 万租户;
- 最大租户占总写入 20%;
- 租户内事务要求强一致;
- 跨租户报表允许 5 分钟延迟;
- 单租户迁移不能停机超过数秒;
- 单 Shard 故障不能影响其他 Shard。
参考设计
一级键:tenant_id
路由:tenant_id → virtual bucket → regional shard
超大租户:Directory Override → dedicated shard
事务:租户内本地 PostgreSQL 事务
读取:Primary + 每 Shard Replica
报表:Outbox/CDC → 分析系统
ID:UUIDv7 或可靠 Snowflake 类 ID
迁移:Backfill + CDC + Validation + Epoch Fencing
HA:每 Shard 独立 Primary/Replica/PITR
控制面:强一致 Metadata Service
限流:全局、区域、Shard、Tenant 四级
最大租户不能只依赖普通 Tenant Hash,应预留:
- 专用 Shard;
- 二级实体分片;
- 热点计数 Bucket;
- 独立容量和限流策略。
20. 检查清单
- 我能够用 SLO、排队和资源增量定义可持续容量。
- 我能够证明瓶颈位于 CPU、内存、I/O、WAL、锁、Pool 或复制。
- 我不会仅凭表行数决定分片。
- 我能够解释为什么读副本不能扩展写入。
- 我能够准确区分分区和分片。
- 我能够比较 Tenant、Hash、Range 和 Directory-Based Sharding。
- 我能够识别 Hot Shard、Hot Key、Hot Row 和数据倾斜。
- 我能够说明 Virtual Bucket 的收益和边界。
- 我能够设计 Global ID,但不会把 ID 与路由或提交顺序混淆。
- 我能够实现有界 Fan-Out 和部分失败策略。
- 我能够设计跨分片排序、聚合和 Keyset Pagination。
- 我能够判断何时使用本地事务、2PC、Saga 或 Outbox。
- 我能够设计 Backfill、CDC、Validation、Fencing 和 Cutover。
- 我能够解释 Cutover 后 Rollback 为什么需要反向同步。
- 我能够为每个 Shard 计算连接池预算。
- 我不会为每个租户创建独立 Pool。
- 我能够在 Go 中处理 SQLSTATE、完整事务重试和 Commit 不确定。
- 我能够设计分片级备份、复制、Failover 和 Metadata HA。
- 我能够分析分片带来的读、写、网络和空间放大。
- 我能够用实验而不是固定参数证明扩展方案有效。
官方资料来源
- PostgreSQL 18 版本状态、支持周期与 Release Notes。(PostgreSQL)
- PostgreSQL 累计统计系统、
pg_stat_io、pg_stat_wal、pg_stat_replication和pg_stat_checkpointer。(PostgreSQL) pg_stat_statements官方文档。(PostgreSQL)- Hot Standby 只读语义和 Recovery Conflict。(PostgreSQL)
- PostgreSQL 声明式分区及唯一约束限制。(PostgreSQL)
- Materialized View 与并发刷新条件。(PostgreSQL)
- PostgreSQL 两阶段提交。(PostgreSQL)
- PostgreSQL 逻辑复制。(PostgreSQL)
- PostgreSQL 18 UUIDv7。(PostgreSQL)
pgxpoolAPI 与 Pool Statistics。(Go Packages)- Citus Distribution Column、Sharding Model 和在线 Rebalancing。(Citus Documentation)